```
feat: 添加飞书用户模块和订阅功能支持 - 新增feishu_users模块用于处理飞书用户身份验证和权限管理 - 新增subscriptions模块用于处理订阅相关功能 - 新增personalization模块用于个性化服务 - 在alembic迁移配置中注册新的模型模块 - 在API路由器中添加feishu_users和subscriptions路由 - 实现事件调度服务的改进,包括错误处理和状态更新优化 - 添加飞书命令处理的权限检查机制 - 实现飞书应用票据事件处理 - 改进审计日志记录功能 ```
This commit is contained in:
@@ -1,12 +1,13 @@
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from app.modules.ai_agent.constants import (
|
||||
CHAT_USER_CONTENT_TEMPLATE,
|
||||
COMPANY_MANAGEMENT_SYSTEM_INSTRUCTIONS,
|
||||
UNEXPECTED_HERMES_RESPONSE,
|
||||
AIChatRole,
|
||||
AIContextKey,
|
||||
AIErrorKey,
|
||||
AIHttpPayloadKey,
|
||||
AIResponseKey,
|
||||
@@ -41,6 +42,7 @@ def _chat_completion_payload(response: Any, error_key: AIErrorKey) -> dict[str,
|
||||
|
||||
|
||||
def _chat_messages(prompt: str, context: dict[str, Any] | None = None) -> list[dict[str, str]]:
|
||||
request_context = context or {}
|
||||
return [
|
||||
{
|
||||
AIHttpPayloadKey.ROLE: AIChatRole.SYSTEM,
|
||||
@@ -48,14 +50,57 @@ def _chat_messages(prompt: str, context: dict[str, Any] | None = None) -> list[d
|
||||
},
|
||||
{
|
||||
AIHttpPayloadKey.ROLE: AIChatRole.USER,
|
||||
AIHttpPayloadKey.CONTENT: CHAT_USER_CONTENT_TEMPLATE.format(
|
||||
context=context or {},
|
||||
task=prompt,
|
||||
),
|
||||
AIHttpPayloadKey.CONTENT: _ordered_context(prompt, request_context),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _ordered_context(prompt: str, context: dict[str, Any]) -> str:
|
||||
"""Serialize trusted personalization layers in their required precedence."""
|
||||
|
||||
company_rules = context.get(AIContextKey.COMPANY_RULES)
|
||||
if company_rules is None:
|
||||
company_rules = context.get(AIContextKey.USER_RULES) or []
|
||||
controlled_keys = {
|
||||
AIContextKey.USER_RULES,
|
||||
AIContextKey.COMPANY_RULES,
|
||||
AIContextKey.PERSONAL_RULES,
|
||||
AIContextKey.PREFERENCES,
|
||||
AIContextKey.INTERESTS,
|
||||
AIContextKey.LOCAL_MEMORY,
|
||||
AIContextKey.CONVERSATION_HISTORY,
|
||||
AIContextKey.PROVIDER_SESSION_ID,
|
||||
AIContextKey.ALLOW_PROVIDER_MEMORY,
|
||||
}
|
||||
current_context = {
|
||||
str(key): value for key, value in context.items() if key not in controlled_keys
|
||||
}
|
||||
sections = [
|
||||
("公司规则", company_rules),
|
||||
("个人规则", context.get(AIContextKey.PERSONAL_RULES) or []),
|
||||
(
|
||||
"当前请求",
|
||||
{
|
||||
"prompt": prompt,
|
||||
"context": current_context,
|
||||
},
|
||||
),
|
||||
(
|
||||
"个人偏好与兴趣",
|
||||
{
|
||||
"preferences": context.get(AIContextKey.PREFERENCES) or [],
|
||||
"interests": context.get(AIContextKey.INTERESTS) or [],
|
||||
},
|
||||
),
|
||||
("个人相关记忆", context.get(AIContextKey.LOCAL_MEMORY) or []),
|
||||
("当前会话历史", context.get(AIContextKey.CONVERSATION_HISTORY) or []),
|
||||
]
|
||||
return "\n\n".join(
|
||||
f"{title}:\n{json.dumps(value, ensure_ascii=False, default=str)}"
|
||||
for title, value in sections
|
||||
)
|
||||
|
||||
|
||||
def _service_root(base_url: str, suffix: str) -> str:
|
||||
root = base_url.rstrip("/")
|
||||
normalized_suffix = suffix.rstrip("/")
|
||||
|
||||
@@ -15,6 +15,7 @@ from app.modules.ai_agent.constants import (
|
||||
AUTHORIZATION_BEARER_TEMPLATE,
|
||||
UNEXPECTED_HERMES_RESPONSE,
|
||||
AIErrorKey,
|
||||
AIContextKey,
|
||||
AIHttpHeader,
|
||||
AIHttpPath,
|
||||
AIHttpPayloadKey,
|
||||
@@ -38,11 +39,16 @@ class HermesAdapter(AIAdapter):
|
||||
headers[AIHttpHeader.AUTHORIZATION] = AUTHORIZATION_BEARER_TEMPLATE.format(
|
||||
token=self.settings.hermes_api_key
|
||||
)
|
||||
if self.settings.hermes_session_id:
|
||||
headers[AIHttpHeader.HERMES_SESSION_ID] = self.settings.hermes_session_id
|
||||
request_context = context or {}
|
||||
session_id = (
|
||||
request_context.get(AIContextKey.PROVIDER_SESSION_ID)
|
||||
or self.settings.hermes_session_id
|
||||
)
|
||||
if session_id:
|
||||
headers[AIHttpHeader.HERMES_SESSION_ID] = str(session_id)
|
||||
payload = {
|
||||
AIHttpPayloadKey.MODEL: self.settings.hermes_model,
|
||||
AIHttpPayloadKey.MESSAGES: _chat_messages(prompt, context),
|
||||
AIHttpPayloadKey.MESSAGES: _chat_messages(prompt, request_context),
|
||||
AIHttpPayloadKey.STREAM: False,
|
||||
}
|
||||
with httpx.Client(timeout=300, trust_env=False) as client:
|
||||
|
||||
@@ -32,7 +32,14 @@ class OpenClawHermesAdapter(AIAdapter):
|
||||
|
||||
def ask(self, prompt: str, context: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
base_context = context or {}
|
||||
recall = self._recall_memory(prompt, base_context)
|
||||
allow_provider_memory = bool(
|
||||
base_context.get(AIContextKey.ALLOW_PROVIDER_MEMORY, True)
|
||||
)
|
||||
recall = (
|
||||
self._recall_memory(prompt, base_context)
|
||||
if allow_provider_memory
|
||||
else {AIResponseKey.ANSWER: "", AIResponseKey.RAW: {}}
|
||||
)
|
||||
openclaw = self._openclaw_context(base_context)
|
||||
hermes_context = {
|
||||
**base_context,
|
||||
@@ -41,10 +48,14 @@ class OpenClawHermesAdapter(AIAdapter):
|
||||
AIContextKey.OPENCLAW: openclaw,
|
||||
}
|
||||
hermes_result = self.hermes.ask(prompt, hermes_context)
|
||||
remember = self._remember_interaction(
|
||||
prompt,
|
||||
base_context,
|
||||
hermes_result[AIResponseKey.ANSWER],
|
||||
remember = (
|
||||
self._remember_interaction(
|
||||
prompt,
|
||||
base_context,
|
||||
hermes_result[AIResponseKey.ANSWER],
|
||||
)
|
||||
if allow_provider_memory
|
||||
else {AIResponseKey.OK: False, AIResponseKey.RAW: {}}
|
||||
)
|
||||
return {
|
||||
AIResponseKey.ANSWER: hermes_result[AIResponseKey.ANSWER],
|
||||
|
||||
@@ -60,6 +60,14 @@ class AIContextKey(StrEnum):
|
||||
REQUEST_CONTEXT = "request_context"
|
||||
ASSISTANT_ANSWER = "assistant_answer"
|
||||
USER_RULES = "user_rules"
|
||||
COMPANY_RULES = "company_rules"
|
||||
PERSONAL_RULES = "personal_rules"
|
||||
PREFERENCES = "preferences"
|
||||
INTERESTS = "interests"
|
||||
CONVERSATION_HISTORY = "conversation_history"
|
||||
PROVIDER_SESSION_ID = "provider_session_id"
|
||||
ALLOW_PROVIDER_MEMORY = "allow_provider_memory"
|
||||
EXECUTION_MODE = "execution_mode"
|
||||
|
||||
|
||||
class AIMemoryMode(StrEnum):
|
||||
@@ -67,6 +75,14 @@ class AIMemoryMode(StrEnum):
|
||||
WRITE = "memory_write"
|
||||
|
||||
|
||||
class AIExecutionMode(StrEnum):
|
||||
INTERNAL = "internal"
|
||||
PERSONALIZED = "personalized"
|
||||
PREFERENCE_EXTRACTION = "preference_extraction"
|
||||
SCHEDULED_PRIVATE = "scheduled_private"
|
||||
SCHEDULED_GROUP = "scheduled_group"
|
||||
|
||||
|
||||
class AIHttpPath(StrEnum):
|
||||
CHAT_COMPLETIONS = "/chat/completions"
|
||||
HEALTH = "/health"
|
||||
@@ -95,13 +111,6 @@ class AIHttpPayloadKey(StrEnum):
|
||||
MESSAGE = "message"
|
||||
|
||||
|
||||
class AIToolAuditKey(StrEnum):
|
||||
TOOL = "tool"
|
||||
ACTION = "action"
|
||||
ARGS = "args"
|
||||
SESSION_KEY = "session_key"
|
||||
|
||||
|
||||
class AIChatRole(StrEnum):
|
||||
SYSTEM = "system"
|
||||
USER = "user"
|
||||
@@ -133,16 +142,25 @@ CHAT_USER_CONTENT_TEMPLATE = "Context:\n{context}\n\nTask:\n{task}"
|
||||
AUTHORIZATION_BEARER_TEMPLATE = "Bearer {token}"
|
||||
NOOP_PROVIDER_ANSWER = (
|
||||
"AI provider is not configured yet. This is a deterministic placeholder. "
|
||||
"Set MODEL_PROVIDER to openclaw_hermes, openclaw, hermes, or direct_llm "
|
||||
"after credentials are ready."
|
||||
"Set MODEL_PROVIDER to openclaw_hermes, hermes, or direct_llm after "
|
||||
"credentials are ready."
|
||||
)
|
||||
AI_UNAVAILABLE_ANSWER = "AI 当前不可用,请稍后重试。"
|
||||
PREFERENCE_EXTRACTION_INSTRUCTIONS = (
|
||||
"Extract only durable user communication preferences from the supplied user text. "
|
||||
"Allowed categories are language, tone, detail, topic, and interest. "
|
||||
"Return strict JSON only in this shape: "
|
||||
'{"preferences":[{"category":"language","value":"中文"}]}. '
|
||||
"Return an empty preferences list when there is no durable preference. "
|
||||
"Never return secrets, credentials, health, religion, politics, sexual orientation, "
|
||||
"performance, compensation, or confidential financial information."
|
||||
)
|
||||
OPENCLAW_TOOL_COMPLETED_ANSWER = "OpenClaw tool invocation completed."
|
||||
DIRECT_LLM_API_KEY_MISSING = "DIRECT_LLM_API_KEY is not configured"
|
||||
UNEXPECTED_HERMES_RESPONSE = "Unexpected chat completion response"
|
||||
OPENCLAW_CHAT_PROVIDER_REQUIRED = (
|
||||
"OpenClaw Gateway is not configured as a chat provider. "
|
||||
"Provide context.openclaw_tool for /tools/invoke, or use "
|
||||
"MODEL_PROVIDER=hermes/openclaw_hermes for AI answers."
|
||||
"OpenClaw Gateway is not exposed as a tool-execution provider. "
|
||||
"Use MODEL_PROVIDER=hermes, openclaw_hermes, or direct_llm for AI answers."
|
||||
)
|
||||
OPENCLAW_TOOL_NOT_ALLOWED = "OpenClaw tool is not allowed"
|
||||
OPENCLAW_ACTION_NOT_ALLOWED = "OpenClaw action is not allowed"
|
||||
|
||||
@@ -2,14 +2,13 @@ from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import ApiPrincipal, require_api_key, require_operations_enabled
|
||||
from app.core.security import ApiPrincipal, require_api_key
|
||||
from app.modules.audit.constants import AuditSource
|
||||
from app.modules.ai_agent.schemas import (
|
||||
AIAskRequest,
|
||||
AIAskResponse,
|
||||
DraftPolicyRequest,
|
||||
InvestmentResearchRequest,
|
||||
OpenClawToolInvokeRequest,
|
||||
)
|
||||
from app.modules.ai_agent.service import AIService
|
||||
|
||||
@@ -38,22 +37,6 @@ def provider_health(
|
||||
return AIService(db).provider_health(actor=principal.actor)
|
||||
|
||||
|
||||
@router.post("/openclaw/tools/invoke")
|
||||
def invoke_openclaw_tool(
|
||||
payload: OpenClawToolInvokeRequest,
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
require_operations_enabled()
|
||||
return AIService(db).invoke_openclaw_tool(
|
||||
tool=payload.tool,
|
||||
action=payload.action,
|
||||
args=payload.args,
|
||||
session_key=payload.session_key,
|
||||
actor=principal.actor,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/draft-policy", response_model=AIAskResponse)
|
||||
def draft_policy(
|
||||
payload: DraftPolicyRequest,
|
||||
|
||||
@@ -3,7 +3,7 @@ from typing import Any
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.core.constants import ActorValue
|
||||
from app.modules.ai_agent.constants import AIDefault, AIRiskPreference
|
||||
from app.modules.ai_agent.constants import AIRiskPreference
|
||||
from app.modules.audit.constants import AuditSource
|
||||
|
||||
|
||||
@@ -15,17 +15,11 @@ class AIAskRequest(BaseModel):
|
||||
|
||||
|
||||
class AIAskResponse(BaseModel):
|
||||
ok: bool = True
|
||||
provider: str
|
||||
answer: str
|
||||
raw: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class OpenClawToolInvokeRequest(BaseModel):
|
||||
tool: str
|
||||
action: str = AIDefault.ACTION_JSON
|
||||
args: dict[str, Any] = Field(default_factory=dict)
|
||||
session_key: str = AIDefault.SESSION_KEY_MAIN
|
||||
actor: str = ActorValue.API
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class DraftPolicyRequest(BaseModel):
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.constants import ActorValue
|
||||
from app.core.config import get_settings
|
||||
from app.modules.ai_agent.adapters import HermesAdapter, OpenClawAdapter, get_adapter
|
||||
from app.modules.ai_agent.constants import (
|
||||
AIDefault,
|
||||
AI_UNAVAILABLE_ANSWER,
|
||||
AI_AUDIT_MAX_DEPTH,
|
||||
AI_AUDIT_MAX_SEQUENCE_ITEMS,
|
||||
AI_AUDIT_MAX_TEXT_LENGTH,
|
||||
AI_AUDIT_REDACTED_VALUE,
|
||||
AI_AUDIT_SENSITIVE_KEYS,
|
||||
AI_AUDIT_TRUNCATED_VALUE,
|
||||
AIToolAuditKey,
|
||||
COMPANY_MANAGEMENT_SYSTEM_INSTRUCTIONS,
|
||||
PREFERENCE_EXTRACTION_INSTRUCTIONS,
|
||||
AIContextKey,
|
||||
AIExecutionMode,
|
||||
AIProviderName,
|
||||
AIRequestKey,
|
||||
AIResponseKey,
|
||||
@@ -30,6 +33,13 @@ from app.modules.audit.constants import (
|
||||
)
|
||||
from app.modules.audit.schemas import AuditLogCreate
|
||||
from app.modules.audit.service import AuditService
|
||||
from app.modules.personalization.services import (
|
||||
ConversationService,
|
||||
PersonalizationContext,
|
||||
PersonalizationContextService,
|
||||
PreferenceService,
|
||||
)
|
||||
from app.modules.personalization.services.preferences import contains_preference_signal
|
||||
|
||||
|
||||
class AIService:
|
||||
@@ -46,15 +56,30 @@ class AIService:
|
||||
actor: str = ActorValue.API,
|
||||
source: str = AuditSource.API,
|
||||
) -> dict[str, Any]:
|
||||
request_context = dict(context or {})
|
||||
_validate_external_context(request_context)
|
||||
adapter = get_adapter()
|
||||
original_context = context or {}
|
||||
adapter_context = dict(original_context)
|
||||
if _is_unavailable_adapter(adapter):
|
||||
response = _unavailable_response(adapter.provider_name)
|
||||
self._audit_ai_response(
|
||||
actor=actor,
|
||||
source=source,
|
||||
request_payload={
|
||||
AIRequestKey.PROMPT: prompt,
|
||||
AIRequestKey.CONTEXT: request_context,
|
||||
},
|
||||
response=response,
|
||||
)
|
||||
return response
|
||||
|
||||
adapter_context = dict(request_context)
|
||||
memory_service = AIMemoryService(self.db)
|
||||
memory_scope = _memory_scope(original_context)
|
||||
memory_subject = _memory_subject(original_context)
|
||||
memory_scope = _memory_scope(request_context)
|
||||
memory_subject = _memory_subject(request_context)
|
||||
user_rules = memory_service.active_rules(
|
||||
scope=memory_scope,
|
||||
subject=memory_subject,
|
||||
owner_id=None,
|
||||
)
|
||||
if user_rules:
|
||||
adapter_context[AIContextKey.USER_RULES] = user_rules
|
||||
@@ -63,6 +88,7 @@ class AIService:
|
||||
scope=memory_scope,
|
||||
subject=memory_subject,
|
||||
actor=actor,
|
||||
owner_id=None,
|
||||
)
|
||||
if local_memory:
|
||||
adapter_context[AIContextKey.LOCAL_MEMORY] = local_memory
|
||||
@@ -73,9 +99,10 @@ class AIService:
|
||||
raw[AIResponseKey.LOCAL_MEMORY] = local_memory
|
||||
memory_record = memory_service.auto_write(
|
||||
prompt=prompt,
|
||||
context=original_context,
|
||||
context=request_context,
|
||||
answer=answer,
|
||||
actor=actor,
|
||||
owner_id=None,
|
||||
)
|
||||
if memory_record is not None:
|
||||
raw[AIResponseKey.MEMORY_WRITE] = {
|
||||
@@ -83,10 +110,253 @@ class AIService:
|
||||
AIMemoryPayloadKey.STATUS: memory_record.status,
|
||||
}
|
||||
response = {
|
||||
AIResponseKey.OK: True,
|
||||
AIResponseKey.PROVIDER: adapter.provider_name,
|
||||
AIResponseKey.ANSWER: answer,
|
||||
AIResponseKey.RAW: raw,
|
||||
}
|
||||
self._audit_ai_response(
|
||||
actor=actor,
|
||||
source=source,
|
||||
request_payload={
|
||||
AIRequestKey.PROMPT: prompt,
|
||||
AIRequestKey.CONTEXT: request_context,
|
||||
},
|
||||
response=response,
|
||||
)
|
||||
return response
|
||||
|
||||
def ask_personalized(
|
||||
self,
|
||||
owner_id: int,
|
||||
chat_type: str,
|
||||
chat_key: str,
|
||||
prompt: str,
|
||||
actor: str = ActorValue.FEISHU,
|
||||
source: str = AuditSource.FEISHU,
|
||||
scope: str = AIMemoryScope.USER,
|
||||
subject: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Answer with one verified owner's isolated personalization context."""
|
||||
|
||||
_validate_owner_id(owner_id)
|
||||
_validate_prompt(prompt)
|
||||
session_id = ConversationService.provider_session_id(
|
||||
owner_id,
|
||||
chat_type,
|
||||
chat_key,
|
||||
)
|
||||
adapter = get_adapter()
|
||||
if _is_unavailable_adapter(adapter):
|
||||
response = _unavailable_response(adapter.provider_name)
|
||||
self._audit_ai_response(
|
||||
actor=actor,
|
||||
source=source,
|
||||
request_payload={
|
||||
"owner_id": owner_id,
|
||||
"chat_type": chat_type,
|
||||
AIContextKey.EXECUTION_MODE: AIExecutionMode.PERSONALIZED,
|
||||
},
|
||||
response=response,
|
||||
)
|
||||
return response
|
||||
|
||||
memory_subject = subject or f"owner:{owner_id}"
|
||||
personalization = PersonalizationContextService(self.db).build(
|
||||
owner_id=owner_id,
|
||||
request=prompt,
|
||||
system_constraints=COMPANY_MANAGEMENT_SYSTEM_INSTRUCTIONS,
|
||||
chat_type=chat_type,
|
||||
chat_key=chat_key,
|
||||
scope=scope,
|
||||
subject=memory_subject,
|
||||
actor=actor,
|
||||
include_company_rules=True,
|
||||
include_personal_context=True,
|
||||
include_history=True,
|
||||
)
|
||||
adapter_context = _adapter_context(
|
||||
personalization,
|
||||
execution_mode=AIExecutionMode.PERSONALIZED,
|
||||
allow_provider_memory=False,
|
||||
provider_session_id=session_id,
|
||||
)
|
||||
result = adapter.ask(prompt, adapter_context)
|
||||
answer = _required_answer(result)
|
||||
raw = dict(result.get(AIResponseKey.RAW, {}))
|
||||
|
||||
ConversationService(self.db).record_turn(
|
||||
owner_id,
|
||||
chat_type,
|
||||
chat_key,
|
||||
user_content=prompt,
|
||||
assistant_content=answer,
|
||||
provider_name=str(adapter.provider_name),
|
||||
)
|
||||
memory_record = AIMemoryService(self.db).auto_write(
|
||||
prompt=prompt,
|
||||
context={
|
||||
AIMemoryPayloadKey.SCOPE: scope,
|
||||
AIMemoryPayloadKey.SUBJECT: memory_subject,
|
||||
},
|
||||
answer=answer,
|
||||
actor=actor,
|
||||
owner_id=owner_id,
|
||||
)
|
||||
if memory_record is not None:
|
||||
raw[AIResponseKey.MEMORY_WRITE] = {
|
||||
AIMemoryPayloadKey.CODE: memory_record.code,
|
||||
AIMemoryPayloadKey.STATUS: memory_record.status,
|
||||
}
|
||||
saved_preferences = self._extract_preferences(
|
||||
adapter=adapter,
|
||||
owner_id=owner_id,
|
||||
user_text=prompt,
|
||||
provider_session_id=session_id,
|
||||
)
|
||||
if saved_preferences:
|
||||
raw["preferences_saved"] = [
|
||||
{
|
||||
"code": item["code"],
|
||||
"category": item["category"],
|
||||
}
|
||||
for item in saved_preferences
|
||||
]
|
||||
|
||||
response = {
|
||||
AIResponseKey.OK: True,
|
||||
AIResponseKey.PROVIDER: adapter.provider_name,
|
||||
AIResponseKey.ANSWER: answer,
|
||||
AIResponseKey.RAW: raw,
|
||||
}
|
||||
self._audit_ai_response(
|
||||
actor=actor,
|
||||
source=source,
|
||||
request_payload={
|
||||
"owner_id": owner_id,
|
||||
"chat_type": chat_type,
|
||||
AIContextKey.EXECUTION_MODE: AIExecutionMode.PERSONALIZED,
|
||||
},
|
||||
response={
|
||||
AIResponseKey.OK: True,
|
||||
AIResponseKey.PROVIDER: adapter.provider_name,
|
||||
},
|
||||
)
|
||||
return response
|
||||
|
||||
def generate_scheduled(
|
||||
self,
|
||||
prompt: str,
|
||||
owner_id: int | None,
|
||||
group: bool,
|
||||
actor: str = "subscription-system",
|
||||
) -> dict[str, Any]:
|
||||
"""Generate side-effect-free scheduled content within its target boundary."""
|
||||
|
||||
_validate_prompt(prompt)
|
||||
if not group:
|
||||
if owner_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Private scheduled generation requires an owner",
|
||||
)
|
||||
_validate_owner_id(owner_id)
|
||||
|
||||
adapter = get_adapter()
|
||||
execution_mode = (
|
||||
AIExecutionMode.SCHEDULED_GROUP
|
||||
if group
|
||||
else AIExecutionMode.SCHEDULED_PRIVATE
|
||||
)
|
||||
if _is_unavailable_adapter(adapter):
|
||||
response = _unavailable_response(adapter.provider_name)
|
||||
self._audit_ai_response(
|
||||
actor=actor,
|
||||
source=AuditSource.FEISHU,
|
||||
request_payload={AIContextKey.EXECUTION_MODE: execution_mode},
|
||||
response=response,
|
||||
)
|
||||
return response
|
||||
|
||||
context_service = PersonalizationContextService(self.db)
|
||||
if group:
|
||||
personalization = context_service.build_group_scheduled(
|
||||
request=prompt,
|
||||
system_constraints=COMPANY_MANAGEMENT_SYSTEM_INSTRUCTIONS,
|
||||
)
|
||||
else:
|
||||
personalization = context_service.build_private_scheduled(
|
||||
owner_id=owner_id,
|
||||
request=prompt,
|
||||
system_constraints=COMPANY_MANAGEMENT_SYSTEM_INSTRUCTIONS,
|
||||
actor=actor,
|
||||
scope=AIMemoryScope.USER,
|
||||
subject=f"owner:{owner_id}",
|
||||
)
|
||||
adapter_context = _adapter_context(
|
||||
personalization,
|
||||
execution_mode=execution_mode,
|
||||
allow_provider_memory=False,
|
||||
provider_session_id=None,
|
||||
)
|
||||
result = adapter.ask(prompt, adapter_context)
|
||||
response = {
|
||||
AIResponseKey.OK: True,
|
||||
AIResponseKey.PROVIDER: adapter.provider_name,
|
||||
AIResponseKey.ANSWER: _required_answer(result),
|
||||
AIResponseKey.RAW: dict(result.get(AIResponseKey.RAW, {})),
|
||||
}
|
||||
self._audit_ai_response(
|
||||
actor=actor,
|
||||
source=AuditSource.FEISHU,
|
||||
request_payload={AIContextKey.EXECUTION_MODE: execution_mode},
|
||||
response={
|
||||
AIResponseKey.OK: True,
|
||||
AIResponseKey.PROVIDER: adapter.provider_name,
|
||||
},
|
||||
)
|
||||
return response
|
||||
|
||||
def _extract_preferences(
|
||||
self,
|
||||
*,
|
||||
adapter: Any,
|
||||
owner_id: int,
|
||||
user_text: str,
|
||||
provider_session_id: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
if not contains_preference_signal(user_text):
|
||||
return []
|
||||
extraction_context = {
|
||||
"preference_source_text": user_text,
|
||||
AIContextKey.PROVIDER_SESSION_ID: f"{provider_session_id}-preferences",
|
||||
AIContextKey.ALLOW_PROVIDER_MEMORY: False,
|
||||
AIContextKey.EXECUTION_MODE: AIExecutionMode.PREFERENCE_EXTRACTION,
|
||||
}
|
||||
try:
|
||||
extraction = adapter.ask(
|
||||
PREFERENCE_EXTRACTION_INSTRUCTIONS,
|
||||
extraction_context,
|
||||
)
|
||||
structured_payload = extraction.get(AIResponseKey.ANSWER, "")
|
||||
return PreferenceService(self.db).save_auto_extraction(
|
||||
owner_id,
|
||||
provider_name=str(adapter.provider_name),
|
||||
user_text=user_text,
|
||||
structured_payload=structured_payload,
|
||||
)
|
||||
except Exception:
|
||||
# Preference inference must never block or change the primary answer.
|
||||
return []
|
||||
|
||||
def _audit_ai_response(
|
||||
self,
|
||||
*,
|
||||
actor: str,
|
||||
source: str,
|
||||
request_payload: dict[str, Any],
|
||||
response: dict[str, Any],
|
||||
) -> None:
|
||||
self.audit.log(
|
||||
AuditLogCreate(
|
||||
actor=actor,
|
||||
@@ -94,14 +364,10 @@ class AIService:
|
||||
action=AuditAction.AI_ASK,
|
||||
target_type=AuditTargetType.AI,
|
||||
risk_level=AuditRiskLevel.MEDIUM,
|
||||
request_payload=_audit_safe_payload({
|
||||
AIRequestKey.PROMPT: prompt,
|
||||
AIRequestKey.CONTEXT: context or {},
|
||||
}),
|
||||
request_payload=_audit_safe_payload(request_payload),
|
||||
response_payload=_audit_safe_payload(response),
|
||||
)
|
||||
)
|
||||
return response
|
||||
|
||||
def run_skill(
|
||||
self,
|
||||
@@ -139,35 +405,6 @@ class AIService:
|
||||
)
|
||||
return response
|
||||
|
||||
def invoke_openclaw_tool(
|
||||
self,
|
||||
tool: str,
|
||||
action: str = AIDefault.ACTION_JSON,
|
||||
args: dict[str, Any] | None = None,
|
||||
session_key: str = AIDefault.SESSION_KEY_MAIN,
|
||||
actor: str = ActorValue.API,
|
||||
) -> dict[str, Any]:
|
||||
result = OpenClawAdapter(get_settings()).invoke_tool(tool, action, args or {}, session_key)
|
||||
response = {AIResponseKey.PROVIDER: AIProviderName.OPENCLAW, AIResponseKey.RESULT: result}
|
||||
self.audit.log(
|
||||
AuditLogCreate(
|
||||
actor=actor,
|
||||
source=AuditSource.OPENCLAW,
|
||||
action=AuditAction.OPENCLAW_TOOLS_INVOKE,
|
||||
target_type=AuditTargetType.OPENCLAW_TOOL,
|
||||
target_id=tool,
|
||||
risk_level=AuditRiskLevel.HIGH,
|
||||
request_payload=_audit_safe_payload({
|
||||
AIToolAuditKey.TOOL: tool,
|
||||
AIToolAuditKey.ACTION: action,
|
||||
AIToolAuditKey.ARGS: args or {},
|
||||
AIToolAuditKey.SESSION_KEY: session_key,
|
||||
}),
|
||||
response_payload=_audit_safe_payload(result),
|
||||
)
|
||||
)
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
def _health_result(check: Any) -> dict[str, Any]:
|
||||
try:
|
||||
@@ -247,3 +484,103 @@ def _memory_scope(context: dict[str, Any]) -> str:
|
||||
def _memory_subject(context: dict[str, Any]) -> str | None:
|
||||
value = context.get(AIContextKey.MEMORY_SUBJECT) or context.get(AIMemoryPayloadKey.SUBJECT)
|
||||
return str(value) if value else None
|
||||
|
||||
|
||||
def _validate_external_context(context: dict[str, Any]) -> None:
|
||||
controlled_keys = {
|
||||
AIContextKey.OPENCLAW_TOOL,
|
||||
AIContextKey.OPENCLAW_ACTION,
|
||||
AIContextKey.OPENCLAW_ARGS,
|
||||
AIContextKey.OPENCLAW_SESSION_KEY,
|
||||
AIContextKey.AGENT_PIPELINE,
|
||||
AIContextKey.HERMES_MEMORY,
|
||||
AIContextKey.LOCAL_MEMORY,
|
||||
AIContextKey.OPENCLAW,
|
||||
AIContextKey.MODE,
|
||||
AIContextKey.USER_PROMPT,
|
||||
AIContextKey.REQUEST_CONTEXT,
|
||||
AIContextKey.ASSISTANT_ANSWER,
|
||||
AIContextKey.USER_RULES,
|
||||
AIContextKey.COMPANY_RULES,
|
||||
AIContextKey.PERSONAL_RULES,
|
||||
AIContextKey.PREFERENCES,
|
||||
AIContextKey.INTERESTS,
|
||||
AIContextKey.CONVERSATION_HISTORY,
|
||||
AIContextKey.PROVIDER_SESSION_ID,
|
||||
AIContextKey.ALLOW_PROVIDER_MEMORY,
|
||||
AIContextKey.EXECUTION_MODE,
|
||||
"system_constraints",
|
||||
"current_request",
|
||||
"personal_memory",
|
||||
"owner_id",
|
||||
"tenant_key",
|
||||
"open_id",
|
||||
}
|
||||
supplied = {str(key) for key in context}
|
||||
if supplied.intersection(str(key) for key in controlled_keys):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Controlled AI context fields are not allowed",
|
||||
)
|
||||
|
||||
|
||||
def _adapter_context(
|
||||
personalization: PersonalizationContext,
|
||||
*,
|
||||
execution_mode: AIExecutionMode,
|
||||
allow_provider_memory: bool,
|
||||
provider_session_id: str | None,
|
||||
) -> dict[str, Any]:
|
||||
context: dict[str, Any] = {
|
||||
AIContextKey.COMPANY_RULES: personalization.company_rules,
|
||||
AIContextKey.PERSONAL_RULES: personalization.personal_rules,
|
||||
AIContextKey.PREFERENCES: personalization.preferences,
|
||||
AIContextKey.INTERESTS: personalization.interests,
|
||||
AIContextKey.LOCAL_MEMORY: personalization.personal_memory,
|
||||
AIContextKey.CONVERSATION_HISTORY: personalization.conversation_history,
|
||||
}
|
||||
if provider_session_id:
|
||||
context[AIContextKey.PROVIDER_SESSION_ID] = provider_session_id
|
||||
context[AIContextKey.ALLOW_PROVIDER_MEMORY] = allow_provider_memory
|
||||
context[AIContextKey.EXECUTION_MODE] = execution_mode
|
||||
return context
|
||||
|
||||
|
||||
def _is_unavailable_adapter(adapter: Any) -> bool:
|
||||
return str(getattr(adapter, "provider_name", "")).strip().lower() == AIProviderName.NOOP
|
||||
|
||||
|
||||
def _unavailable_response(provider_name: Any) -> dict[str, Any]:
|
||||
return {
|
||||
AIResponseKey.OK: False,
|
||||
AIResponseKey.PROVIDER: str(provider_name or AIProviderName.NOOP),
|
||||
AIResponseKey.ANSWER: AI_UNAVAILABLE_ANSWER,
|
||||
AIResponseKey.RAW: {"reason": "provider_unavailable"},
|
||||
AIResponseKey.ERROR: "AI provider unavailable",
|
||||
}
|
||||
|
||||
|
||||
def _required_answer(result: dict[str, Any]) -> str:
|
||||
answer = str(result.get(AIResponseKey.ANSWER) or "").strip()
|
||||
if not answer:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="AI provider returned an empty answer",
|
||||
)
|
||||
return answer
|
||||
|
||||
|
||||
def _validate_owner_id(owner_id: int) -> None:
|
||||
if owner_id <= 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Valid AI owner is required",
|
||||
)
|
||||
|
||||
|
||||
def _validate_prompt(prompt: str) -> None:
|
||||
if not str(prompt).strip():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="AI prompt is required",
|
||||
)
|
||||
|
||||
@@ -19,6 +19,13 @@ class AIMemorySource(StrEnum):
|
||||
HERMES = "hermes"
|
||||
API = "api"
|
||||
USER_RULE = "user_rule"
|
||||
LEGACY_COMPANY = "legacy_company"
|
||||
|
||||
|
||||
class AIMemoryKind(StrEnum):
|
||||
COMPANY_RULE = "company_rule"
|
||||
PERSONAL_RULE = "personal_rule"
|
||||
MEMORY = "memory"
|
||||
|
||||
|
||||
class AIMemoryResponseKey(StrEnum):
|
||||
|
||||
@@ -1,19 +1,46 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import JSON, DateTime, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy import JSON, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.constants import ActorValue
|
||||
from app.core.database import Base
|
||||
from app.core.utils.time import utc_now
|
||||
from app.modules.ai_memory.constants import AIMemoryScope, AIMemorySource, AIMemoryStatus
|
||||
from app.modules.feishu_users.models import FeishuUser
|
||||
from app.modules.ai_memory.constants import (
|
||||
AIMemoryKind,
|
||||
AIMemoryScope,
|
||||
AIMemorySource,
|
||||
AIMemoryStatus,
|
||||
)
|
||||
|
||||
|
||||
class AIMemoryEntry(Base):
|
||||
__tablename__ = "ai_memory_entries"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"owner_id",
|
||||
"fingerprint",
|
||||
name="uq_ai_memory_owner_fingerprint",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
code: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
fingerprint: Mapped[str | None] = mapped_column(
|
||||
String(64), nullable=True, index=True
|
||||
)
|
||||
owner_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("feishu_users.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
owner: Mapped[FeishuUser | None] = relationship()
|
||||
kind: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
default=AIMemoryKind.MEMORY,
|
||||
index=True,
|
||||
)
|
||||
scope: Mapped[str] = mapped_column(String(64), default=AIMemoryScope.GLOBAL, index=True)
|
||||
subject: Mapped[str] = mapped_column(String(128), index=True)
|
||||
content: Mapped[str] = mapped_column(Text)
|
||||
|
||||
@@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import ApiPrincipal, require_api_key, require_operations_enabled
|
||||
from app.core.security import ApiPrincipal, require_api_key
|
||||
from app.modules.ai_memory.constants import AIMemoryResponseKey, AIMemoryStatus
|
||||
from app.modules.ai_memory.schemas import (
|
||||
AIMemoryRecallRequest,
|
||||
@@ -22,14 +22,15 @@ def list_memory(
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
return {
|
||||
AIMemoryResponseKey.ITEMS: AIMemoryService(db).list_entries(
|
||||
scope=scope,
|
||||
subject=subject,
|
||||
status_filter=status,
|
||||
limit=limit,
|
||||
)
|
||||
}
|
||||
items = AIMemoryService(db).list_entries(
|
||||
scope=scope,
|
||||
subject=subject,
|
||||
status_filter=status,
|
||||
limit=limit,
|
||||
owner_id=None,
|
||||
)
|
||||
db.commit()
|
||||
return {AIMemoryResponseKey.ITEMS: items}
|
||||
|
||||
|
||||
@router.post("/memory/recall")
|
||||
@@ -44,6 +45,7 @@ def recall_memory(
|
||||
subject=payload.subject,
|
||||
limit=payload.limit,
|
||||
actor=principal.actor,
|
||||
owner_id=None,
|
||||
)
|
||||
return {AIMemoryResponseKey.ITEMS: items}
|
||||
|
||||
@@ -62,6 +64,7 @@ def list_rules(
|
||||
subject=subject,
|
||||
status_filter=status,
|
||||
limit=limit,
|
||||
owner_id=None,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -72,7 +75,6 @@ def create_rule(
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
require_operations_enabled()
|
||||
return {
|
||||
AIMemoryResponseKey.DATA: AIMemoryService(db).create_rule(
|
||||
content=payload.content,
|
||||
@@ -81,6 +83,7 @@ def create_rule(
|
||||
priority=payload.priority,
|
||||
tags=payload.tags,
|
||||
actor=principal.actor,
|
||||
owner_id=None,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -92,7 +95,6 @@ def update_rule(
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
require_operations_enabled()
|
||||
return {
|
||||
AIMemoryResponseKey.DATA: AIMemoryService(db).update_rule(
|
||||
code=code,
|
||||
@@ -101,5 +103,20 @@ def update_rule(
|
||||
tags=payload.tags,
|
||||
enabled=payload.enabled,
|
||||
actor=principal.actor,
|
||||
owner_id=None,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/rules/{code}")
|
||||
def delete_rule(
|
||||
code: str,
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
AIMemoryService(db).delete_rule(
|
||||
code=code,
|
||||
actor=principal.actor,
|
||||
owner_id=None,
|
||||
)
|
||||
return {AIMemoryResponseKey.DATA: {"code": code, "deleted": True}}
|
||||
|
||||
@@ -14,6 +14,8 @@ class AIMemoryRecallRequest(BaseModel):
|
||||
|
||||
class AIMemoryRead(BaseModel):
|
||||
code: str
|
||||
owner_id: int | None
|
||||
kind: str
|
||||
scope: str
|
||||
subject: str
|
||||
content: str
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from datetime import datetime, timedelta
|
||||
from hashlib import sha256
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy import delete, func, or_, select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
@@ -15,6 +17,7 @@ from app.modules.ai_memory.constants import (
|
||||
AI_MEMORY_MAX_CONTENT_LENGTH,
|
||||
AI_MEMORY_MAX_SUMMARY_LENGTH,
|
||||
AI_MEMORY_MIN_AUTO_WRITE_LENGTH,
|
||||
AIMemoryKind,
|
||||
AIMemoryPayloadKey,
|
||||
AIMemoryScope,
|
||||
AIMemorySource,
|
||||
@@ -50,10 +53,15 @@ class AIMemoryService:
|
||||
subject: str | None = None,
|
||||
status_filter: str = AIMemoryStatus.ACTIVE,
|
||||
limit: int = 100,
|
||||
owner_id: int | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
self._archive_expired()
|
||||
stmt = (
|
||||
select(AIMemoryEntry)
|
||||
.where(AIMemoryEntry.status == status_filter)
|
||||
.where(
|
||||
AIMemoryEntry.status == status_filter,
|
||||
_owner_filter(owner_id),
|
||||
)
|
||||
.order_by(AIMemoryEntry.importance.desc(), AIMemoryEntry.id.desc())
|
||||
.limit(bounded_limit(limit))
|
||||
)
|
||||
@@ -70,16 +78,20 @@ class AIMemoryService:
|
||||
subject: str | None = None,
|
||||
limit: int | None = None,
|
||||
actor: str = ActorValue.API,
|
||||
owner_id: int | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
settings = get_settings()
|
||||
if not settings.ai_memory_enabled:
|
||||
return []
|
||||
limit_value = bounded_limit(limit or settings.ai_memory_recall_limit)
|
||||
self._archive_expired()
|
||||
now = utc_now()
|
||||
stmt = (
|
||||
select(AIMemoryEntry)
|
||||
.where(
|
||||
AIMemoryEntry.status == AIMemoryStatus.ACTIVE,
|
||||
AIMemoryEntry.kind == AIMemoryKind.MEMORY,
|
||||
_owner_filter(owner_id),
|
||||
or_(AIMemoryEntry.expires_at.is_(None), AIMemoryEntry.expires_at > now),
|
||||
AIMemoryEntry.scope.in_({AIMemoryScope.GLOBAL, scope}),
|
||||
)
|
||||
@@ -95,8 +107,6 @@ class AIMemoryService:
|
||||
)
|
||||
candidates = list(self.db.execute(stmt).scalars())
|
||||
items = [item for item in candidates if _matches_query(item, query)]
|
||||
if not items:
|
||||
items = candidates[:limit_value]
|
||||
items = items[:limit_value]
|
||||
for item in items:
|
||||
item.last_used_at = now
|
||||
@@ -126,11 +136,13 @@ class AIMemoryService:
|
||||
context: dict[str, Any],
|
||||
answer: str,
|
||||
actor: str = ActorValue.API,
|
||||
owner_id: int | None = None,
|
||||
) -> AIMemoryEntry | None:
|
||||
settings = get_settings()
|
||||
if not settings.ai_memory_enabled or not settings.ai_memory_auto_write_enabled:
|
||||
return None
|
||||
content = _build_memory_content(prompt, context, answer)
|
||||
self._archive_expired()
|
||||
if len(content) < AI_MEMORY_MIN_AUTO_WRITE_LENGTH:
|
||||
return None
|
||||
scope = str(context.get(AIMemoryPayloadKey.SCOPE) or AIMemoryText.DEFAULT_SCOPE)
|
||||
@@ -143,39 +155,60 @@ class AIMemoryService:
|
||||
},
|
||||
settings.ai_memory_forbidden_keys,
|
||||
):
|
||||
safe_content = str(AIMemoryText.REJECTED_SECRET)
|
||||
record = self._create_entry(
|
||||
scope=scope,
|
||||
subject=subject,
|
||||
content=str(AIMemoryText.REJECTED_SECRET),
|
||||
summary=str(AIMemoryText.REJECTED_SECRET),
|
||||
content=safe_content,
|
||||
summary=safe_content,
|
||||
tags=[str(AIMemoryText.AUTO_TAG)],
|
||||
source=AIMemorySource.AUTO,
|
||||
importance=0,
|
||||
status_value=AIMemoryStatus.REJECTED,
|
||||
actor=actor,
|
||||
fingerprint=_memory_fingerprint(
|
||||
owner_id,
|
||||
scope,
|
||||
subject,
|
||||
content,
|
||||
AIMemoryStatus.REJECTED,
|
||||
),
|
||||
owner_id=owner_id,
|
||||
kind=AIMemoryKind.MEMORY,
|
||||
expires_at=utc_now()
|
||||
+ timedelta(days=settings.ai_memory_auto_write_ttl_days),
|
||||
)
|
||||
return record
|
||||
if _contains_blocked_content(content, settings.ai_memory_blocked_content_terms):
|
||||
safe_content = str(AIMemoryText.REJECTED_SENSITIVE_FACT)
|
||||
return self._create_entry(
|
||||
scope=scope,
|
||||
subject=subject,
|
||||
content=str(AIMemoryText.REJECTED_SENSITIVE_FACT),
|
||||
summary=str(AIMemoryText.REJECTED_SENSITIVE_FACT),
|
||||
content=safe_content,
|
||||
summary=safe_content,
|
||||
tags=[str(AIMemoryText.AUTO_TAG)],
|
||||
source=AIMemorySource.AUTO,
|
||||
importance=0,
|
||||
status_value=AIMemoryStatus.REJECTED,
|
||||
actor=actor,
|
||||
fingerprint=_memory_fingerprint(
|
||||
owner_id,
|
||||
scope,
|
||||
subject,
|
||||
content,
|
||||
AIMemoryStatus.REJECTED,
|
||||
),
|
||||
owner_id=owner_id,
|
||||
kind=AIMemoryKind.MEMORY,
|
||||
expires_at=utc_now()
|
||||
+ timedelta(days=settings.ai_memory_auto_write_ttl_days),
|
||||
)
|
||||
summary = _truncate(answer, AI_MEMORY_MAX_SUMMARY_LENGTH)
|
||||
stored_content = _truncate(content, AI_MEMORY_MAX_CONTENT_LENGTH)
|
||||
record = self._create_entry(
|
||||
scope=scope,
|
||||
subject=subject,
|
||||
content=_truncate(content, AI_MEMORY_MAX_CONTENT_LENGTH),
|
||||
content=stored_content,
|
||||
summary=summary,
|
||||
tags=[str(AIMemoryText.AUTO_TAG)],
|
||||
source=AIMemorySource.AUTO,
|
||||
@@ -183,6 +216,15 @@ class AIMemoryService:
|
||||
status_value=AIMemoryStatus.ACTIVE,
|
||||
actor=actor,
|
||||
expires_at=utc_now() + timedelta(days=settings.ai_memory_auto_write_ttl_days),
|
||||
fingerprint=_memory_fingerprint(
|
||||
owner_id,
|
||||
scope,
|
||||
subject,
|
||||
stored_content,
|
||||
AIMemoryStatus.ACTIVE,
|
||||
),
|
||||
owner_id=owner_id,
|
||||
kind=AIMemoryKind.MEMORY,
|
||||
)
|
||||
return record
|
||||
|
||||
@@ -192,10 +234,19 @@ class AIMemoryService:
|
||||
subject: str | None = None,
|
||||
status_filter: str | None = None,
|
||||
limit: int = 100,
|
||||
owner_id: int | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
kind = (
|
||||
AIMemoryKind.COMPANY_RULE
|
||||
if owner_id is None
|
||||
else AIMemoryKind.PERSONAL_RULE
|
||||
)
|
||||
stmt = (
|
||||
select(AIMemoryEntry)
|
||||
.where(AIMemoryEntry.source == AIMemorySource.USER_RULE)
|
||||
.where(
|
||||
AIMemoryEntry.kind == kind,
|
||||
_owner_filter(owner_id),
|
||||
)
|
||||
.order_by(AIMemoryEntry.importance.desc(), AIMemoryEntry.id.asc())
|
||||
.limit(bounded_limit(limit))
|
||||
)
|
||||
@@ -212,11 +263,19 @@ class AIMemoryService:
|
||||
scope: str = AIMemoryScope.GLOBAL,
|
||||
subject: str | None = None,
|
||||
limit: int = 50,
|
||||
owner_id: int | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
self._archive_expired()
|
||||
kind = (
|
||||
AIMemoryKind.COMPANY_RULE
|
||||
if owner_id is None
|
||||
else AIMemoryKind.PERSONAL_RULE
|
||||
)
|
||||
stmt = (
|
||||
select(AIMemoryEntry)
|
||||
.where(
|
||||
AIMemoryEntry.source == AIMemorySource.USER_RULE,
|
||||
AIMemoryEntry.kind == kind,
|
||||
_owner_filter(owner_id),
|
||||
AIMemoryEntry.status == AIMemoryStatus.ACTIVE,
|
||||
AIMemoryEntry.scope.in_({AIMemoryScope.GLOBAL, scope}),
|
||||
)
|
||||
@@ -249,6 +308,7 @@ class AIMemoryService:
|
||||
priority: int,
|
||||
tags: list[str] | None,
|
||||
actor: str,
|
||||
owner_id: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
self._validate_rule(content, priority)
|
||||
record = self._create_entry(
|
||||
@@ -262,6 +322,12 @@ class AIMemoryService:
|
||||
status_value=AIMemoryStatus.ACTIVE,
|
||||
actor=actor,
|
||||
audit_action=AuditAction.AI_RULE_CREATE,
|
||||
owner_id=owner_id,
|
||||
kind=(
|
||||
AIMemoryKind.COMPANY_RULE
|
||||
if owner_id is None
|
||||
else AIMemoryKind.PERSONAL_RULE
|
||||
),
|
||||
)
|
||||
return serialize_model(record)
|
||||
|
||||
@@ -273,11 +339,18 @@ class AIMemoryService:
|
||||
tags: list[str] | None,
|
||||
enabled: bool | None,
|
||||
actor: str,
|
||||
owner_id: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
kind = (
|
||||
AIMemoryKind.COMPANY_RULE
|
||||
if owner_id is None
|
||||
else AIMemoryKind.PERSONAL_RULE
|
||||
)
|
||||
record = self.db.execute(
|
||||
select(AIMemoryEntry).where(
|
||||
AIMemoryEntry.code == code,
|
||||
AIMemoryEntry.source == AIMemorySource.USER_RULE,
|
||||
AIMemoryEntry.kind == kind,
|
||||
_owner_filter(owner_id),
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if record is None:
|
||||
@@ -312,6 +385,50 @@ class AIMemoryService:
|
||||
self.db.refresh(record)
|
||||
return serialize_model(record)
|
||||
|
||||
def delete_rule(
|
||||
self,
|
||||
code: str,
|
||||
actor: str,
|
||||
owner_id: int | None = None,
|
||||
) -> None:
|
||||
"""Delete a rule only within the requested company or personal owner scope."""
|
||||
|
||||
kind = (
|
||||
AIMemoryKind.COMPANY_RULE
|
||||
if owner_id is None
|
||||
else AIMemoryKind.PERSONAL_RULE
|
||||
)
|
||||
record = self.db.execute(
|
||||
select(AIMemoryEntry).where(
|
||||
AIMemoryEntry.code == code,
|
||||
AIMemoryEntry.kind == kind,
|
||||
_owner_filter(owner_id),
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if record is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="AI rule not found")
|
||||
self.db.delete(record)
|
||||
self.audit.record(
|
||||
AuditLogCreate(
|
||||
actor=actor,
|
||||
source=AuditSource.AI_MEMORY,
|
||||
action=AuditAction.AI_RULE_UPDATE,
|
||||
target_type=AuditTargetType.AI_MEMORY,
|
||||
target_id=code,
|
||||
risk_level=AuditRiskLevel.MEDIUM,
|
||||
response_payload={"deleted": True},
|
||||
)
|
||||
)
|
||||
self.db.commit()
|
||||
|
||||
def delete_owner_entries(self, owner_id: int) -> int:
|
||||
"""Stage deletion of all personal rules and memories for an owner."""
|
||||
|
||||
result = self.db.execute(
|
||||
delete(AIMemoryEntry).where(AIMemoryEntry.owner_id == owner_id)
|
||||
)
|
||||
return max(0, int(result.rowcount or 0))
|
||||
|
||||
def _validate_rule(self, content: str, priority: int) -> None:
|
||||
if not AI_USER_RULE_MIN_PRIORITY <= priority <= AI_USER_RULE_MAX_PRIORITY:
|
||||
raise HTTPException(
|
||||
@@ -325,11 +442,46 @@ class AIMemoryService:
|
||||
)
|
||||
|
||||
def count_by_status(self) -> dict[str, int]:
|
||||
self._archive_expired()
|
||||
rows = self.db.execute(
|
||||
select(AIMemoryEntry.status, func.count()).group_by(AIMemoryEntry.status)
|
||||
).all()
|
||||
return {str(status_value): int(count) for status_value, count in rows}
|
||||
|
||||
def _archive_expired(self) -> int:
|
||||
now = utc_now()
|
||||
result = self.db.execute(
|
||||
update(AIMemoryEntry)
|
||||
.where(
|
||||
AIMemoryEntry.status.in_(
|
||||
{
|
||||
AIMemoryStatus.ACTIVE,
|
||||
AIMemoryStatus.REJECTED,
|
||||
}
|
||||
),
|
||||
AIMemoryEntry.expires_at.is_not(None),
|
||||
AIMemoryEntry.expires_at <= now,
|
||||
)
|
||||
.values(
|
||||
status=AIMemoryStatus.ARCHIVED,
|
||||
updated_at=now,
|
||||
)
|
||||
)
|
||||
archived = max(0, int(result.rowcount or 0))
|
||||
return archived
|
||||
|
||||
def _find_by_fingerprint(
|
||||
self,
|
||||
owner_id: int | None,
|
||||
fingerprint: str,
|
||||
) -> AIMemoryEntry | None:
|
||||
return self.db.execute(
|
||||
select(AIMemoryEntry).where(
|
||||
AIMemoryEntry.fingerprint == fingerprint,
|
||||
_owner_filter(owner_id),
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
def _create_entry(
|
||||
self,
|
||||
scope: str,
|
||||
@@ -343,12 +495,23 @@ class AIMemoryService:
|
||||
actor: str,
|
||||
audit_action: str = AuditAction.AI_MEMORY_WRITE,
|
||||
expires_at: datetime | None = None,
|
||||
fingerprint: str | None = None,
|
||||
owner_id: int | None = None,
|
||||
kind: str = AIMemoryKind.MEMORY,
|
||||
) -> AIMemoryEntry:
|
||||
if fingerprint:
|
||||
existing = self._find_by_fingerprint(owner_id, fingerprint)
|
||||
if existing is not None:
|
||||
return self._reuse_entry(existing, status_value, expires_at)
|
||||
|
||||
record = AIMemoryEntry(
|
||||
code=(
|
||||
f"{AI_MEMORY_CODE_PREFIX}-{utc_now():%Y%m%d%H%M%S%f}-"
|
||||
f"{uuid4().hex[:8]}"
|
||||
),
|
||||
fingerprint=fingerprint,
|
||||
owner_id=owner_id,
|
||||
kind=kind,
|
||||
scope=scope,
|
||||
subject=subject,
|
||||
content=content,
|
||||
@@ -360,8 +523,20 @@ class AIMemoryService:
|
||||
actor=actor,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
self.db.add(record)
|
||||
self.db.flush()
|
||||
if fingerprint:
|
||||
try:
|
||||
with self.db.begin_nested():
|
||||
self.db.add(record)
|
||||
self.db.flush()
|
||||
except IntegrityError:
|
||||
existing = self._find_by_fingerprint(owner_id, fingerprint)
|
||||
if existing is None:
|
||||
raise
|
||||
return self._reuse_entry(existing, status_value, expires_at)
|
||||
else:
|
||||
self.db.add(record)
|
||||
self.db.flush()
|
||||
|
||||
self.audit.record(
|
||||
AuditLogCreate(
|
||||
actor=actor,
|
||||
@@ -397,6 +572,21 @@ class AIMemoryService:
|
||||
self.db.refresh(record)
|
||||
return record
|
||||
|
||||
def _reuse_entry(
|
||||
self,
|
||||
record: AIMemoryEntry,
|
||||
status_value: str,
|
||||
expires_at: datetime | None,
|
||||
) -> AIMemoryEntry:
|
||||
if record.status != AIMemoryStatus.ARCHIVED:
|
||||
return record
|
||||
record.status = status_value
|
||||
record.expires_at = expires_at
|
||||
record.updated_at = utc_now()
|
||||
self.db.commit()
|
||||
self.db.refresh(record)
|
||||
return record
|
||||
|
||||
|
||||
def _build_memory_content(prompt: str, context: dict[str, Any], answer: str) -> str:
|
||||
context_text = ", ".join(
|
||||
@@ -427,6 +617,24 @@ def _contains_blocked_content(value: str, blocked_terms: list[str]) -> bool:
|
||||
return any(term.lower() in lowered for term in blocked_terms if term.strip())
|
||||
|
||||
|
||||
def _memory_fingerprint(
|
||||
owner_id: int | None,
|
||||
scope: str,
|
||||
subject: str,
|
||||
content: str,
|
||||
status_value: str,
|
||||
) -> str:
|
||||
owner_key = "company" if owner_id is None else f"owner:{owner_id}"
|
||||
value = "\0".join((owner_key, scope, subject, status_value, content))
|
||||
return sha256(value.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _owner_filter(owner_id: int | None):
|
||||
if owner_id is None:
|
||||
return AIMemoryEntry.owner_id.is_(None)
|
||||
return AIMemoryEntry.owner_id == owner_id
|
||||
|
||||
|
||||
def _matches_query(entry: AIMemoryEntry, query: str) -> bool:
|
||||
query_text = query.lower().strip()
|
||||
if not query_text:
|
||||
|
||||
@@ -4,7 +4,6 @@ from enum import StrEnum
|
||||
class AuditAction(StrEnum):
|
||||
AI_ASK = "ai.ask"
|
||||
AI_PROVIDER_HEALTH = "ai.provider_health"
|
||||
OPENCLAW_TOOLS_INVOKE = "openclaw.tools.invoke"
|
||||
GENERATE_EVENTS = "generate_events"
|
||||
FEISHU_WEBHOOK_EVENT = "webhook_event"
|
||||
FEISHU_LONG_CONNECTION_EVENT = "long_connection_event"
|
||||
@@ -34,7 +33,6 @@ class AuditRiskLevel(StrEnum):
|
||||
|
||||
class AuditSource(StrEnum):
|
||||
API = "api"
|
||||
OPENCLAW = "openclaw"
|
||||
RISK = "risk"
|
||||
FEISHU = "feishu"
|
||||
LEGACY_MYSQL = "legacy_mysql"
|
||||
@@ -47,7 +45,6 @@ class AuditSource(StrEnum):
|
||||
|
||||
class AuditTargetType(StrEnum):
|
||||
AI = "ai"
|
||||
OPENCLAW_TOOL = "openclaw_tool"
|
||||
RISK_EVENTS = "risk-events"
|
||||
WORK_REPORTS = "work-reports"
|
||||
ENTERPRISE_ANALYTICS = "enterprise-analytics"
|
||||
@@ -62,21 +59,3 @@ class AuditStatus(StrEnum):
|
||||
|
||||
|
||||
AUDIT_REDACTED_VALUE = "[REDACTED]"
|
||||
AUDIT_SENSITIVE_KEYS = frozenset(
|
||||
{
|
||||
"authorization",
|
||||
"api_key",
|
||||
"apikey",
|
||||
"access_token",
|
||||
"tenant_access_token",
|
||||
"token",
|
||||
"secret",
|
||||
"password",
|
||||
"openclaw_gateway_token",
|
||||
"hermes_api_key",
|
||||
"direct_llm_api_key",
|
||||
"market_data_token",
|
||||
"feishu_app_secret",
|
||||
"feishu_verification_token",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -4,9 +4,10 @@ from typing import Any
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.http.masking import is_sensitive_key
|
||||
from app.core.http.pagination import bounded_limit
|
||||
from app.core.http.request_context import get_request_id
|
||||
from app.modules.audit.constants import AUDIT_REDACTED_VALUE, AUDIT_SENSITIVE_KEYS
|
||||
from app.modules.audit.constants import AUDIT_REDACTED_VALUE
|
||||
from app.modules.audit.models import AuditLog
|
||||
from app.modules.audit.schemas import AuditLogCreate
|
||||
|
||||
@@ -16,7 +17,7 @@ def _redact(value: Any) -> Any:
|
||||
safe: dict[str, Any] = {}
|
||||
for key, item in value.items():
|
||||
key_text = str(key)
|
||||
if key_text.lower() in AUDIT_SENSITIVE_KEYS:
|
||||
if is_sensitive_key(key_text):
|
||||
safe[key_text] = AUDIT_REDACTED_VALUE
|
||||
else:
|
||||
safe[key_text] = _redact(item)
|
||||
@@ -34,6 +35,12 @@ def _dump(value: Any | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return value
|
||||
if isinstance(parsed, (dict, list)):
|
||||
return json.dumps(_redact(parsed), ensure_ascii=False, default=str)
|
||||
return value
|
||||
return json.dumps(_redact(value), ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import Boolean, Date, DateTime, Integer, Numeric, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
Date,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
Numeric,
|
||||
String,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from app.modules.feishu_users.models import FeishuUser
|
||||
from app.modules.business.models.common import TimestampMixin
|
||||
|
||||
|
||||
@@ -80,8 +90,16 @@ class MarketAnnouncement(Base, TimestampMixin):
|
||||
|
||||
class MarketWatchlist(Base, TimestampMixin):
|
||||
__tablename__ = "market_watchlists"
|
||||
__table_args__ = (UniqueConstraint("actor", "symbol", name="uq_market_watchlist_actor_symbol"),)
|
||||
__table_args__ = (
|
||||
UniqueConstraint("owner_id", "symbol", name="uq_market_watchlist_owner_symbol"),
|
||||
)
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
owner_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("feishu_users.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
owner: Mapped[FeishuUser | None] = relationship()
|
||||
actor: Mapped[str] = mapped_column(String(128), index=True)
|
||||
symbol: Mapped[str] = mapped_column(String(32), index=True)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
|
||||
|
||||
@@ -15,4 +15,6 @@ def dashboard_summary(
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
_ = principal
|
||||
return mask_configured(DashboardService(db).summary())
|
||||
result = mask_configured(DashboardService(db).summary())
|
||||
db.commit()
|
||||
return result
|
||||
|
||||
@@ -4,8 +4,7 @@ from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.ai_memory.constants import AIMemoryStatus
|
||||
from app.modules.ai_memory.models import AIMemoryEntry
|
||||
from app.modules.audit.models import AuditLog
|
||||
from app.modules.ai_memory.service import AIMemoryService
|
||||
from app.modules.business.constants import DONE_STATUSES, PROJECT_CLOSED_STATUSES, StatusValue
|
||||
from app.modules.business.models import (
|
||||
LegacySyncRun,
|
||||
@@ -53,7 +52,8 @@ class DashboardService:
|
||||
WorkflowInstance,
|
||||
WorkflowInstance.status == WorkflowStatus.FAILED,
|
||||
)
|
||||
active_ai_memory = self._count(AIMemoryEntry, AIMemoryEntry.status == AIMemoryStatus.ACTIVE)
|
||||
memory_counts = AIMemoryService(self.db).count_by_status()
|
||||
active_ai_memory = memory_counts.get(AIMemoryStatus.ACTIVE, 0)
|
||||
heartbeat_summary = ObservabilityService(self.db).heartbeat_summary()
|
||||
latest_reports = self.db.execute(
|
||||
select(WorkReport).order_by(WorkReport.id.desc()).limit(5)
|
||||
@@ -64,9 +64,6 @@ class DashboardService:
|
||||
latest_sync_runs = self.db.execute(
|
||||
select(LegacySyncRun).order_by(LegacySyncRun.id.desc()).limit(10)
|
||||
).scalars()
|
||||
latest_audit_logs = self.db.execute(
|
||||
select(AuditLog).order_by(AuditLog.id.desc()).limit(10)
|
||||
).scalars()
|
||||
risk_summary = self.risks.summary()
|
||||
return {
|
||||
"metrics": {
|
||||
@@ -94,7 +91,6 @@ class DashboardService:
|
||||
"latest_reports": [serialize_model(item) for item in latest_reports],
|
||||
"latest_push_runs": [serialize_model(item) for item in latest_push_runs],
|
||||
"latest_sync_runs": [serialize_model(item) for item in latest_sync_runs],
|
||||
"latest_audit_logs": [serialize_model(item) for item in latest_audit_logs],
|
||||
}
|
||||
|
||||
def _count(self, model: type, *conditions: Any) -> int:
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.constants import ActorValue
|
||||
@@ -35,7 +37,10 @@ class EventQueryMixin:
|
||||
settings = get_settings()
|
||||
now = utc_now()
|
||||
record = DomainEvent(
|
||||
event_id=f"{EVENT_CODE_PREFIX}-{now:%Y%m%d%H%M%S%f}",
|
||||
event_id=(
|
||||
f"{EVENT_CODE_PREFIX}-{now:%Y%m%d%H%M%S%f}-"
|
||||
f"{uuid4().hex[:8]}"
|
||||
),
|
||||
event_type=event_type,
|
||||
source=source,
|
||||
aggregate_type=aggregate_type,
|
||||
@@ -46,8 +51,20 @@ class EventQueryMixin:
|
||||
next_attempt_at=now,
|
||||
max_attempts=settings.event_dispatch_max_attempts,
|
||||
)
|
||||
self.db.add(record)
|
||||
self.db.flush()
|
||||
if not idempotency_key:
|
||||
self.db.add(record)
|
||||
self.db.flush()
|
||||
return record
|
||||
|
||||
try:
|
||||
with self.db.begin_nested():
|
||||
self.db.add(record)
|
||||
self.db.flush()
|
||||
except IntegrityError:
|
||||
existing = self._find_idempotent_event(idempotency_key)
|
||||
if existing is None:
|
||||
raise
|
||||
return existing
|
||||
return record
|
||||
|
||||
def emit(
|
||||
|
||||
63
app/modules/feishu/app_tickets.py
Normal file
63
app/modules/feishu/app_tickets.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils.time import utc_now
|
||||
from app.modules.feishu.models import FeishuAppTicket
|
||||
|
||||
APP_TICKET_EVENT_TYPE = "app_ticket"
|
||||
APP_TICKET_PAYLOAD_KEY = "app_ticket"
|
||||
|
||||
|
||||
class FeishuAppTicketService:
|
||||
"""Persist the latest ticket received through a verified Feishu event."""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def get_ticket(self, app_id: str) -> str | None:
|
||||
app_id_value = str(app_id).strip()
|
||||
if not app_id_value:
|
||||
return None
|
||||
return self.db.scalar(
|
||||
select(FeishuAppTicket.app_ticket).where(
|
||||
FeishuAppTicket.app_id == app_id_value
|
||||
)
|
||||
)
|
||||
|
||||
def store_verified(self, app_id: str, ticket: str) -> FeishuAppTicket:
|
||||
app_id_value = str(app_id).strip()
|
||||
ticket_value = str(ticket).strip()
|
||||
if not app_id_value or not ticket_value:
|
||||
raise ValueError("Verified Feishu app ticket fields are required")
|
||||
|
||||
now = utc_now()
|
||||
record = self.db.execute(
|
||||
select(FeishuAppTicket)
|
||||
.where(FeishuAppTicket.app_id == app_id_value)
|
||||
.with_for_update()
|
||||
).scalar_one_or_none()
|
||||
if record is None:
|
||||
record = FeishuAppTicket(
|
||||
app_id=app_id_value,
|
||||
app_ticket=ticket_value,
|
||||
received_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
try:
|
||||
with self.db.begin_nested():
|
||||
self.db.add(record)
|
||||
self.db.flush()
|
||||
except IntegrityError:
|
||||
record = self.db.execute(
|
||||
select(FeishuAppTicket)
|
||||
.where(FeishuAppTicket.app_id == app_id_value)
|
||||
.with_for_update()
|
||||
).scalar_one()
|
||||
|
||||
record.app_ticket = ticket_value
|
||||
record.received_at = now
|
||||
record.updated_at = now
|
||||
self.db.commit()
|
||||
self.db.refresh(record)
|
||||
return record
|
||||
@@ -1,67 +1,185 @@
|
||||
import json
|
||||
import time
|
||||
from threading import RLock
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.constants import BEARER_TOKEN_TEMPLATE, HttpHeader
|
||||
from app.core.config import get_settings
|
||||
from app.core.constants import BEARER_TOKEN_TEMPLATE, HttpHeader
|
||||
from app.modules.feishu.constants import (
|
||||
FEISHU_APP_TICKET_MISSING,
|
||||
FEISHU_APP_TOKEN_PATH,
|
||||
FEISHU_AUTH_MISSING,
|
||||
FEISHU_DEFAULT_TOKEN_EXPIRE_SECONDS,
|
||||
FEISHU_MESSAGE_PATH,
|
||||
FEISHU_IMAGE_PATH,
|
||||
FEISHU_MESSAGE_PATH,
|
||||
FEISHU_RECEIVE_ID_MISSING,
|
||||
FEISHU_STORE_TENANT_TOKEN_PATH,
|
||||
FEISHU_SUCCESS_CODE,
|
||||
FEISHU_TENANT_KEY_MISSING,
|
||||
FEISHU_TENANT_TOKEN_PATH,
|
||||
FEISHU_TOKEN_EXPIRE_SAFETY_SECONDS,
|
||||
FeishuAppType,
|
||||
FeishuMessageType,
|
||||
FeishuPayloadKey,
|
||||
FeishuReceiveIdType,
|
||||
)
|
||||
from app.modules.feishu.errors import FeishuAPIError
|
||||
|
||||
_RETRYABLE_PROVIDER_CODES = frozenset(
|
||||
{
|
||||
99991400,
|
||||
99991401,
|
||||
99991402,
|
||||
99991403,
|
||||
}
|
||||
)
|
||||
_RETRYABLE_PROVIDER_TERMS = (
|
||||
"rate limit",
|
||||
"too many request",
|
||||
"temporar",
|
||||
"timeout",
|
||||
"busy",
|
||||
"限流",
|
||||
"频率",
|
||||
"超时",
|
||||
"繁忙",
|
||||
)
|
||||
|
||||
|
||||
class FeishuClient:
|
||||
"""Small Feishu Open Platform client for tenant token and message APIs."""
|
||||
"""Small Feishu Open Platform client with tenant-isolated token caches."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
def __init__(self, db: Session | None = None) -> None:
|
||||
self.settings = get_settings()
|
||||
self.db = db
|
||||
self._tenant_access_token: str | None = None
|
||||
self._token_expires_at: float = 0
|
||||
self._app_access_tokens: dict[str, tuple[str, float]] = {}
|
||||
self._store_tenant_access_tokens: dict[tuple[str, str], tuple[str, float]] = {}
|
||||
self._token_lock = RLock()
|
||||
|
||||
def _is_configured(self) -> bool:
|
||||
return bool(self.settings.feishu_app_id and self.settings.feishu_app_secret)
|
||||
|
||||
def _get_tenant_access_token(self) -> str:
|
||||
def _get_tenant_access_token(self, tenant_key: str | None = None) -> str:
|
||||
if not self._is_configured():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=FEISHU_AUTH_MISSING,
|
||||
)
|
||||
if self._tenant_access_token and time.time() < self._token_expires_at:
|
||||
return self._tenant_access_token
|
||||
if self.settings.feishu_app_type == FeishuAppType.STORE:
|
||||
return self._get_store_tenant_access_token(tenant_key)
|
||||
return self._get_self_tenant_access_token()
|
||||
|
||||
url = f"{self.settings.feishu_base_url}{FEISHU_TENANT_TOKEN_PATH}"
|
||||
payload = {
|
||||
FeishuPayloadKey.APP_ID: self.settings.feishu_app_id,
|
||||
FeishuPayloadKey.APP_SECRET: self.settings.feishu_app_secret,
|
||||
}
|
||||
with httpx.Client(timeout=20) as client:
|
||||
response = client.post(url, json=payload)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if data.get(FeishuPayloadKey.CODE) != FEISHU_SUCCESS_CODE:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail={FeishuPayloadKey.FEISHU_ERROR: data},
|
||||
def _get_self_tenant_access_token(self) -> str:
|
||||
with self._token_lock:
|
||||
if (
|
||||
self._tenant_access_token
|
||||
and time.time() < self._token_expires_at
|
||||
):
|
||||
return self._tenant_access_token
|
||||
data = self._post(
|
||||
f"{self.settings.feishu_base_url}{FEISHU_TENANT_TOKEN_PATH}",
|
||||
operation="Feishu self tenant token request",
|
||||
json={
|
||||
FeishuPayloadKey.APP_ID: self.settings.feishu_app_id,
|
||||
FeishuPayloadKey.APP_SECRET: self.settings.feishu_app_secret,
|
||||
},
|
||||
)
|
||||
self._tenant_access_token = data[FeishuPayloadKey.TENANT_ACCESS_TOKEN]
|
||||
expire_seconds = int(
|
||||
data.get(FeishuPayloadKey.EXPIRE, FEISHU_DEFAULT_TOKEN_EXPIRE_SECONDS)
|
||||
)
|
||||
self._token_expires_at = time.time() + expire_seconds - FEISHU_TOKEN_EXPIRE_SAFETY_SECONDS
|
||||
return self._tenant_access_token
|
||||
token = self._required_token(
|
||||
data,
|
||||
FeishuPayloadKey.TENANT_ACCESS_TOKEN,
|
||||
"Feishu self tenant token response",
|
||||
)
|
||||
self._tenant_access_token = token
|
||||
self._token_expires_at = self._expires_at(data)
|
||||
return token
|
||||
|
||||
def _get_store_app_access_token(self) -> str:
|
||||
app_id = str(self.settings.feishu_app_id)
|
||||
with self._token_lock:
|
||||
cached = self._get_cached(self._app_access_tokens, app_id)
|
||||
if cached is not None:
|
||||
return cached
|
||||
app_ticket = self._get_app_ticket(app_id)
|
||||
data = self._post(
|
||||
f"{self.settings.feishu_base_url}{FEISHU_APP_TOKEN_PATH}",
|
||||
operation="Feishu store app token request",
|
||||
json={
|
||||
FeishuPayloadKey.APP_ID: app_id,
|
||||
FeishuPayloadKey.APP_SECRET: self.settings.feishu_app_secret,
|
||||
FeishuPayloadKey.APP_TICKET: app_ticket,
|
||||
},
|
||||
)
|
||||
token = self._required_token(
|
||||
data,
|
||||
FeishuPayloadKey.APP_ACCESS_TOKEN,
|
||||
"Feishu store app token response",
|
||||
)
|
||||
self._app_access_tokens[app_id] = (token, self._expires_at(data))
|
||||
return token
|
||||
|
||||
def _get_store_tenant_access_token(self, tenant_key: str | None) -> str:
|
||||
normalized_tenant_key = str(tenant_key or "").strip()
|
||||
if not normalized_tenant_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=FEISHU_TENANT_KEY_MISSING,
|
||||
)
|
||||
app_id = str(self.settings.feishu_app_id)
|
||||
cache_key = (app_id, normalized_tenant_key)
|
||||
with self._token_lock:
|
||||
cached = self._get_cached(
|
||||
self._store_tenant_access_tokens,
|
||||
cache_key,
|
||||
)
|
||||
if cached is not None:
|
||||
return cached
|
||||
app_access_token = self._get_store_app_access_token()
|
||||
data = self._post(
|
||||
f"{self.settings.feishu_base_url}{FEISHU_STORE_TENANT_TOKEN_PATH}",
|
||||
operation="Feishu store tenant token request",
|
||||
json={
|
||||
FeishuPayloadKey.APP_ACCESS_TOKEN: app_access_token,
|
||||
FeishuPayloadKey.TENANT_KEY: normalized_tenant_key,
|
||||
},
|
||||
)
|
||||
token = self._required_token(
|
||||
data,
|
||||
FeishuPayloadKey.TENANT_ACCESS_TOKEN,
|
||||
"Feishu store tenant token response",
|
||||
)
|
||||
self._store_tenant_access_tokens[cache_key] = (
|
||||
token,
|
||||
self._expires_at(data),
|
||||
)
|
||||
return token
|
||||
|
||||
def _get_app_ticket(self, app_id: str) -> str:
|
||||
ticket: Any = None
|
||||
if self.db is not None:
|
||||
from app.modules.feishu.app_tickets import FeishuAppTicketService
|
||||
|
||||
ticket = FeishuAppTicketService(self.db).get_ticket(app_id)
|
||||
if ticket is not None and not isinstance(ticket, str):
|
||||
ticket = getattr(ticket, "app_ticket", None) or getattr(
|
||||
ticket,
|
||||
"ticket",
|
||||
None,
|
||||
)
|
||||
normalized = str(ticket or "").strip()
|
||||
if not normalized:
|
||||
normalized = str(self.settings.feishu_app_ticket or "").strip()
|
||||
if not normalized:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=FEISHU_APP_TICKET_MISSING,
|
||||
)
|
||||
return normalized
|
||||
|
||||
def send_message(
|
||||
self,
|
||||
@@ -69,77 +187,207 @@ class FeishuClient:
|
||||
receive_id_type: str,
|
||||
msg_type: str,
|
||||
content: dict[str, Any],
|
||||
uuid: str | None = None,
|
||||
tenant_key: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
token = self._get_tenant_access_token()
|
||||
url = f"{self.settings.feishu_base_url}{FEISHU_MESSAGE_PATH}"
|
||||
token = self._get_tenant_access_token(tenant_key)
|
||||
headers = {HttpHeader.AUTHORIZATION: BEARER_TOKEN_TEMPLATE.format(token=token)}
|
||||
params = {FeishuPayloadKey.RECEIVE_ID_TYPE: receive_id_type}
|
||||
payload = {
|
||||
FeishuPayloadKey.RECEIVE_ID: receive_id,
|
||||
FeishuPayloadKey.MESSAGE_TYPE: msg_type,
|
||||
FeishuPayloadKey.CONTENT: json.dumps(content, ensure_ascii=False),
|
||||
}
|
||||
with httpx.Client(timeout=20) as client:
|
||||
response = client.post(url, headers=headers, params=params, json=payload)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return data
|
||||
if uuid:
|
||||
payload[FeishuPayloadKey.UUID] = uuid
|
||||
return self._post(
|
||||
f"{self.settings.feishu_base_url}{FEISHU_MESSAGE_PATH}",
|
||||
operation="Feishu message request",
|
||||
headers=headers,
|
||||
params={FeishuPayloadKey.RECEIVE_ID_TYPE: receive_id_type},
|
||||
json=payload,
|
||||
)
|
||||
|
||||
def send_text(
|
||||
self,
|
||||
text: str,
|
||||
receive_id: str | None = None,
|
||||
receive_id_type: str = FeishuReceiveIdType.CHAT_ID,
|
||||
) -> dict:
|
||||
chat_id = receive_id or self.settings.feishu_default_chat_id
|
||||
if not chat_id:
|
||||
uuid: str | None = None,
|
||||
tenant_key: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
target_id = receive_id or self.settings.feishu_default_chat_id
|
||||
if not target_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=FEISHU_RECEIVE_ID_MISSING,
|
||||
)
|
||||
resolved_tenant_key = tenant_key
|
||||
if receive_id is None and not resolved_tenant_key:
|
||||
resolved_tenant_key = self.settings.feishu_default_tenant_key
|
||||
return self.send_message(
|
||||
chat_id,
|
||||
target_id,
|
||||
receive_id_type,
|
||||
FeishuMessageType.TEXT,
|
||||
{FeishuPayloadKey.TEXT: text},
|
||||
uuid,
|
||||
resolved_tenant_key,
|
||||
)
|
||||
|
||||
def upload_image(
|
||||
self,
|
||||
image: bytes,
|
||||
filename: str = "lifecycle-report.png",
|
||||
tenant_key: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
token = self._get_tenant_access_token()
|
||||
url = f"{self.settings.feishu_base_url}{FEISHU_IMAGE_PATH}"
|
||||
resolved_tenant_key = tenant_key or self.settings.feishu_default_tenant_key
|
||||
token = self._get_tenant_access_token(resolved_tenant_key)
|
||||
headers = {HttpHeader.AUTHORIZATION: BEARER_TOKEN_TEMPLATE.format(token=token)}
|
||||
with httpx.Client(timeout=30) as client:
|
||||
response = client.post(
|
||||
url,
|
||||
headers=headers,
|
||||
data={FeishuPayloadKey.IMAGE_TYPE: FeishuPayloadKey.MESSAGE},
|
||||
files={
|
||||
FeishuPayloadKey.IMAGE: (filename, image, "image/png"),
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if data.get(FeishuPayloadKey.CODE) != FEISHU_SUCCESS_CODE:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail={FeishuPayloadKey.FEISHU_ERROR: data},
|
||||
)
|
||||
return data
|
||||
return self._post(
|
||||
f"{self.settings.feishu_base_url}{FEISHU_IMAGE_PATH}",
|
||||
operation="Feishu image upload request",
|
||||
timeout=30,
|
||||
headers=headers,
|
||||
data={FeishuPayloadKey.IMAGE_TYPE: FeishuPayloadKey.MESSAGE},
|
||||
files={
|
||||
FeishuPayloadKey.IMAGE: (filename, image, "image/png"),
|
||||
},
|
||||
)
|
||||
|
||||
def send_card(
|
||||
self,
|
||||
card: dict[str, Any],
|
||||
receive_id: str | None = None,
|
||||
receive_id_type: str = FeishuReceiveIdType.CHAT_ID,
|
||||
) -> dict:
|
||||
chat_id = receive_id or self.settings.feishu_default_chat_id
|
||||
if not chat_id:
|
||||
uuid: str | None = None,
|
||||
tenant_key: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
target_id = receive_id or self.settings.feishu_default_chat_id
|
||||
if not target_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=FEISHU_RECEIVE_ID_MISSING,
|
||||
)
|
||||
return self.send_message(chat_id, receive_id_type, FeishuMessageType.INTERACTIVE, card)
|
||||
resolved_tenant_key = tenant_key
|
||||
if receive_id is None and not resolved_tenant_key:
|
||||
resolved_tenant_key = self.settings.feishu_default_tenant_key
|
||||
return self.send_message(
|
||||
target_id,
|
||||
receive_id_type,
|
||||
FeishuMessageType.INTERACTIVE,
|
||||
card,
|
||||
uuid,
|
||||
resolved_tenant_key,
|
||||
)
|
||||
|
||||
def _post(
|
||||
self,
|
||||
url: str,
|
||||
*,
|
||||
operation: str,
|
||||
timeout: int = 20,
|
||||
**kwargs: Any,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
with httpx.Client(timeout=timeout) as client:
|
||||
response = client.post(url, **kwargs)
|
||||
except httpx.HTTPError:
|
||||
raise FeishuAPIError(
|
||||
f"{operation} failed",
|
||||
retryable=True,
|
||||
) from None
|
||||
if not status.HTTP_200_OK <= response.status_code < status.HTTP_300_MULTIPLE_CHOICES:
|
||||
raise FeishuAPIError(
|
||||
f"{operation} returned an HTTP error",
|
||||
retryable=_is_retryable_http_status(response.status_code),
|
||||
http_status=response.status_code,
|
||||
)
|
||||
try:
|
||||
data = response.json()
|
||||
except ValueError:
|
||||
raise FeishuAPIError(
|
||||
f"{operation} response was not valid JSON",
|
||||
retryable=True,
|
||||
http_status=response.status_code,
|
||||
) from None
|
||||
if not isinstance(data, dict):
|
||||
raise FeishuAPIError(
|
||||
f"{operation} response was not a JSON object",
|
||||
retryable=True,
|
||||
http_status=response.status_code,
|
||||
)
|
||||
provider_code = data.get(FeishuPayloadKey.CODE)
|
||||
if provider_code != FEISHU_SUCCESS_CODE:
|
||||
raise FeishuAPIError(
|
||||
f"{operation} returned a non-zero business code",
|
||||
retryable=_is_retryable_business_error(data),
|
||||
http_status=response.status_code,
|
||||
provider_code=provider_code,
|
||||
provider_response={
|
||||
FeishuPayloadKey.CODE: provider_code,
|
||||
},
|
||||
)
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def _required_token(
|
||||
data: dict[str, Any],
|
||||
key: FeishuPayloadKey,
|
||||
operation: str,
|
||||
) -> str:
|
||||
token = data.get(key)
|
||||
if not isinstance(token, str) or not token.strip():
|
||||
raise FeishuAPIError(
|
||||
f"{operation} did not include the required credential",
|
||||
retryable=True,
|
||||
provider_code=data.get(FeishuPayloadKey.CODE),
|
||||
provider_response={
|
||||
FeishuPayloadKey.CODE: data.get(FeishuPayloadKey.CODE),
|
||||
},
|
||||
)
|
||||
return token
|
||||
|
||||
@staticmethod
|
||||
def _expires_at(data: dict[str, Any]) -> float:
|
||||
try:
|
||||
expire_seconds = int(
|
||||
data.get(
|
||||
FeishuPayloadKey.EXPIRE,
|
||||
FEISHU_DEFAULT_TOKEN_EXPIRE_SECONDS,
|
||||
)
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
expire_seconds = FEISHU_DEFAULT_TOKEN_EXPIRE_SECONDS
|
||||
usable_seconds = max(
|
||||
1,
|
||||
expire_seconds - FEISHU_TOKEN_EXPIRE_SAFETY_SECONDS,
|
||||
)
|
||||
return time.time() + usable_seconds
|
||||
|
||||
@staticmethod
|
||||
def _get_cached(
|
||||
cache: dict[Any, tuple[str, float]],
|
||||
key: Any,
|
||||
) -> str | None:
|
||||
entry = cache.get(key)
|
||||
if entry is None:
|
||||
return None
|
||||
token, expires_at = entry
|
||||
if time.time() < expires_at:
|
||||
return token
|
||||
cache.pop(key, None)
|
||||
return None
|
||||
|
||||
|
||||
def _is_retryable_http_status(status_code: int) -> bool:
|
||||
return (
|
||||
status_code == status.HTTP_429_TOO_MANY_REQUESTS
|
||||
or status_code >= status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
|
||||
def _is_retryable_business_error(data: dict[str, Any]) -> bool:
|
||||
code = data.get(FeishuPayloadKey.CODE)
|
||||
if code in _RETRYABLE_PROVIDER_CODES:
|
||||
return True
|
||||
message = str(data.get("msg") or "").casefold()
|
||||
return any(term in message for term in _RETRYABLE_PROVIDER_TERMS)
|
||||
|
||||
@@ -3,6 +3,12 @@ from enum import StrEnum
|
||||
|
||||
class FeishuReceiveIdType(StrEnum):
|
||||
CHAT_ID = "chat_id"
|
||||
OPEN_ID = "open_id"
|
||||
|
||||
|
||||
class FeishuAppType(StrEnum):
|
||||
SELF = "self"
|
||||
STORE = "store"
|
||||
|
||||
|
||||
class FeishuMessageType(StrEnum):
|
||||
@@ -16,24 +22,30 @@ class FeishuEventSource(StrEnum):
|
||||
|
||||
|
||||
class FeishuPayloadKey(StrEnum):
|
||||
APP_ACCESS_TOKEN = "app_access_token"
|
||||
APP_ID = "app_id"
|
||||
APP_SECRET = "app_secret"
|
||||
APP_TICKET = "app_ticket"
|
||||
CARD = "card"
|
||||
CHALLENGE = "challenge"
|
||||
CHAT_TYPE = "chat_type"
|
||||
CODE = "code"
|
||||
CONFIG = "config"
|
||||
CONTENT = "content"
|
||||
DIV = "div"
|
||||
DATA = "data"
|
||||
ELEMENTS = "elements"
|
||||
ENCRYPT = "encrypt"
|
||||
EXPIRE = "expire"
|
||||
FEISHU_ERROR = "feishu_error"
|
||||
HEADER = "header"
|
||||
IMAGE = "image"
|
||||
IMAGE_KEY = "image_key"
|
||||
IMAGE_TYPE = "image_type"
|
||||
ID = "id"
|
||||
IMG = "img"
|
||||
IMG_KEY = "img_key"
|
||||
KEY = "key"
|
||||
ALT = "alt"
|
||||
EVENT = "event"
|
||||
EVENT_ID = "event_id"
|
||||
@@ -42,6 +54,8 @@ class FeishuPayloadKey(StrEnum):
|
||||
MESSAGE = "message"
|
||||
MESSAGE_ID = "message_id"
|
||||
MESSAGE_TYPE = "msg_type"
|
||||
MENTIONS = "mentions"
|
||||
NAME = "name"
|
||||
OPEN_ID = "open_id"
|
||||
PLAIN_TEXT = "plain_text"
|
||||
RECEIVE_ID = "receive_id"
|
||||
@@ -50,17 +64,23 @@ class FeishuPayloadKey(StrEnum):
|
||||
SENDER_ID = "sender_id"
|
||||
TAG = "tag"
|
||||
TENANT_ACCESS_TOKEN = "tenant_access_token"
|
||||
TENANT_KEY = "tenant_key"
|
||||
TEXT = "text"
|
||||
TITLE = "title"
|
||||
TOKEN = "token"
|
||||
UNION_ID = "union_id"
|
||||
USER_ID = "user_id"
|
||||
UUID = "uuid"
|
||||
WIDE_SCREEN_MODE = "wide_screen_mode"
|
||||
|
||||
|
||||
class FeishuCommandKey(StrEnum):
|
||||
TEXT = "text"
|
||||
CHAT_ID = "chat_id"
|
||||
CHAT_TYPE = "chat_type"
|
||||
ACTOR = "actor"
|
||||
MENTIONS = "mentions"
|
||||
PRINCIPAL = "principal"
|
||||
|
||||
|
||||
class FeishuResponseKey(StrEnum):
|
||||
@@ -85,10 +105,32 @@ class FeishuCommandResultKey(StrEnum):
|
||||
|
||||
|
||||
class FeishuCommandName(StrEnum):
|
||||
PERMISSION_DENIED = "permission_denied"
|
||||
HELP = "help"
|
||||
USER_SET_ADMIN = "user_set_admin"
|
||||
USER_SET_USER = "user_set_user"
|
||||
USER_DISABLE = "user_disable"
|
||||
USER_ENABLE = "user_enable"
|
||||
PREFERENCE_SET = "preference_set"
|
||||
PREFERENCE_LIST = "preference_list"
|
||||
PREFERENCE_DELETE = "preference_delete"
|
||||
CONVERSATION_RESET = "conversation_reset"
|
||||
PERSONAL_DATA_SUMMARY = "personal_data_summary"
|
||||
PERSONAL_DATA_ERASURE_REQUEST = "personal_data_erasure_request"
|
||||
PERSONAL_DATA_ERASURE_CONFIRM = "personal_data_erasure_confirm"
|
||||
SUBSCRIPTION_CREATE = "subscription_create"
|
||||
SUBSCRIPTION_LIST = "subscription_list"
|
||||
SUBSCRIPTION_PAUSE = "subscription_pause"
|
||||
SUBSCRIPTION_RESUME = "subscription_resume"
|
||||
SUBSCRIPTION_CANCEL = "subscription_cancel"
|
||||
SUBSCRIPTION_TIMEZONE = "subscription_timezone"
|
||||
SUBSCRIPTION_QUIET_HOURS = "subscription_quiet_hours"
|
||||
RULE_CREATE = "rule_create"
|
||||
RULE_LIST = "rule_list"
|
||||
RULE_DISABLE = "rule_disable"
|
||||
RULE_ENABLE = "rule_enable"
|
||||
RULE_UPDATE = "rule_update"
|
||||
RULE_DELETE = "rule_delete"
|
||||
FINANCE_NEEDS = "finance_needs"
|
||||
PROJECT_FINANCE = "project_finance"
|
||||
MARKET_OVERVIEW = "market_overview"
|
||||
@@ -123,11 +165,15 @@ class FeishuCardKey(StrEnum):
|
||||
VALUE = "value"
|
||||
|
||||
|
||||
FEISHU_APP_TOKEN_PATH = "/auth/v3/app_access_token"
|
||||
FEISHU_STORE_TENANT_TOKEN_PATH = "/auth/v3/tenant_access_token"
|
||||
FEISHU_TENANT_TOKEN_PATH = "/auth/v3/tenant_access_token/internal"
|
||||
FEISHU_MESSAGE_PATH = "/im/v1/messages"
|
||||
FEISHU_IMAGE_PATH = "/im/v1/images"
|
||||
FEISHU_DEFAULT_OPEN_API_DOMAIN = "https://open.feishu.cn"
|
||||
FEISHU_AUTH_MISSING = "Feishu app credentials are not configured"
|
||||
FEISHU_APP_TICKET_MISSING = "Feishu store app ticket is not available"
|
||||
FEISHU_TENANT_KEY_MISSING = "tenant_key is required for Feishu store apps"
|
||||
FEISHU_VERIFICATION_TOKEN_REQUIRED = "FEISHU_VERIFICATION_TOKEN is required"
|
||||
FEISHU_INVALID_TOKEN = "Invalid Feishu token"
|
||||
FEISHU_RECEIVE_ID_MISSING = "receive_id or FEISHU_DEFAULT_CHAT_ID is required"
|
||||
|
||||
29
app/modules/feishu/errors.py
Normal file
29
app/modules/feishu/errors.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
|
||||
class FeishuAPIError(HTTPException):
|
||||
"""Normalized outbound Feishu failure with retry classification."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
detail: str,
|
||||
*,
|
||||
retryable: bool,
|
||||
http_status: int | None = None,
|
||||
provider_code: int | str | None = None,
|
||||
provider_response: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
status_code=(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
if retryable
|
||||
else status.HTTP_502_BAD_GATEWAY
|
||||
),
|
||||
detail=detail,
|
||||
)
|
||||
self.retryable = retryable
|
||||
self.http_status = http_status
|
||||
self.provider_code = provider_code
|
||||
self.provider_response = provider_response or {}
|
||||
131
app/modules/feishu/event_verification.py
Normal file
131
app/modules/feishu/event_verification.py
Normal file
@@ -0,0 +1,131 @@
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
from hashlib import sha256
|
||||
from secrets import compare_digest
|
||||
from typing import Any, Mapping
|
||||
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
from cryptography.hazmat.primitives.padding import PKCS7
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.modules.feishu.constants import FeishuPayloadKey
|
||||
|
||||
_SIGNATURE_MAX_AGE_SECONDS = 300
|
||||
_SIGNATURE_HEADER = "x-lark-signature"
|
||||
_TIMESTAMP_HEADER = "x-lark-request-timestamp"
|
||||
_NONCE_HEADER = "x-lark-request-nonce"
|
||||
|
||||
|
||||
class FeishuWebhookVerifier:
|
||||
"""Verify, decrypt, and normalize an HTTP webhook before business handling."""
|
||||
|
||||
def verify(
|
||||
self,
|
||||
raw_body: bytes,
|
||||
headers: Mapping[str, str],
|
||||
) -> dict[str, Any]:
|
||||
settings = get_settings()
|
||||
if settings.feishu_encrypt_key:
|
||||
self._verify_signature(raw_body, headers, settings.feishu_encrypt_key)
|
||||
payload = self._load_json(raw_body)
|
||||
if FeishuPayloadKey.ENCRYPT in payload:
|
||||
if not settings.feishu_encrypt_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="FEISHU_ENCRYPT_KEY is required for encrypted webhooks",
|
||||
)
|
||||
payload = self._decrypt(
|
||||
str(payload[FeishuPayloadKey.ENCRYPT]),
|
||||
settings.feishu_encrypt_key,
|
||||
)
|
||||
self._verify_token(payload, settings.feishu_verification_token)
|
||||
return payload
|
||||
|
||||
@staticmethod
|
||||
def _load_json(raw_body: bytes) -> dict[str, Any]:
|
||||
try:
|
||||
payload = json.loads(raw_body)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid Feishu webhook JSON",
|
||||
) from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid Feishu webhook payload",
|
||||
)
|
||||
return payload
|
||||
|
||||
@staticmethod
|
||||
def _verify_signature(
|
||||
raw_body: bytes,
|
||||
headers: Mapping[str, str],
|
||||
encrypt_key: str,
|
||||
) -> None:
|
||||
normalized = {str(key).lower(): str(value) for key, value in headers.items()}
|
||||
timestamp = normalized.get(_TIMESTAMP_HEADER)
|
||||
nonce = normalized.get(_NONCE_HEADER)
|
||||
signature = normalized.get(_SIGNATURE_HEADER)
|
||||
if not timestamp or not nonce or not signature:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Missing Feishu webhook signature headers",
|
||||
)
|
||||
try:
|
||||
request_time = int(timestamp)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid Feishu webhook timestamp",
|
||||
) from exc
|
||||
if abs(int(time.time()) - request_time) > _SIGNATURE_MAX_AGE_SECONDS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Expired Feishu webhook signature",
|
||||
)
|
||||
signed = (
|
||||
timestamp.encode("utf-8")
|
||||
+ nonce.encode("utf-8")
|
||||
+ encrypt_key.encode("utf-8")
|
||||
+ raw_body
|
||||
)
|
||||
expected = sha256(signed).hexdigest()
|
||||
if not compare_digest(signature, expected):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid Feishu webhook signature",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _decrypt(encrypted: str, encrypt_key: str) -> dict[str, Any]:
|
||||
try:
|
||||
key = sha256(encrypt_key.encode("utf-8")).digest()
|
||||
encrypted_bytes = base64.b64decode(encrypted, validate=True)
|
||||
decryptor = Cipher(algorithms.AES(key), modes.CBC(key[:16])).decryptor()
|
||||
padded = decryptor.update(encrypted_bytes) + decryptor.finalize()
|
||||
unpadder = PKCS7(algorithms.AES.block_size).unpadder()
|
||||
cleartext = unpadder.update(padded) + unpadder.finalize()
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid encrypted Feishu webhook",
|
||||
) from exc
|
||||
return FeishuWebhookVerifier._load_json(cleartext)
|
||||
|
||||
@staticmethod
|
||||
def _verify_token(payload: dict[str, Any], expected: str | None) -> None:
|
||||
if not expected:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="FEISHU_VERIFICATION_TOKEN is required",
|
||||
)
|
||||
header = payload.get(FeishuPayloadKey.HEADER) or {}
|
||||
token = payload.get(FeishuPayloadKey.TOKEN) or header.get(FeishuPayloadKey.TOKEN)
|
||||
if not token or not compare_digest(str(token), expected):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid Feishu token",
|
||||
)
|
||||
@@ -31,10 +31,18 @@ def _sdk_event_to_payload(event: Any) -> dict[str, Any]:
|
||||
|
||||
|
||||
def _handle_message_event(event: Any) -> None:
|
||||
_handle_verified_sdk_event(event)
|
||||
|
||||
|
||||
def _handle_app_ticket_event(event: Any) -> None:
|
||||
_handle_verified_sdk_event(event)
|
||||
|
||||
|
||||
def _handle_verified_sdk_event(event: Any) -> None:
|
||||
payload = _sdk_event_to_payload(event)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
result = FeishuEventService(db).handle_event(
|
||||
result = FeishuEventService(db)._handle_verified_event(
|
||||
payload,
|
||||
source=FeishuEventSource.LONG_CONNECTION,
|
||||
auto_reply=True,
|
||||
@@ -62,6 +70,7 @@ def run_long_connection() -> None:
|
||||
settings.feishu_verification_token or "",
|
||||
)
|
||||
.register_p2_im_message_receive_v1(_handle_message_event)
|
||||
.register_p1_customized_event("app_ticket", _handle_app_ticket_event)
|
||||
.build()
|
||||
)
|
||||
client = lark.ws.Client(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Integer, String
|
||||
from sqlalchemy import DateTime, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.database import Base
|
||||
@@ -16,3 +16,17 @@ class FeishuEventReceipt(Base):
|
||||
event_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
message_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
received_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)
|
||||
|
||||
|
||||
class FeishuAppTicket(Base):
|
||||
__tablename__ = "feishu_app_tickets"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
app_id: Mapped[str] = mapped_column(String(128), unique=True, index=True)
|
||||
app_ticket: Mapped[str] = mapped_column(Text)
|
||||
received_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
default=utc_now,
|
||||
onupdate=utc_now,
|
||||
)
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import ApiPrincipal, require_api_key
|
||||
from app.application.feishu import FeishuCommandService, FeishuEventService
|
||||
from app.modules.feishu.constants import FeishuEventSource, FeishuPayloadKey, FeishuResponseKey
|
||||
from app.modules.feishu.event_verification import FeishuWebhookVerifier
|
||||
from app.modules.feishu.schemas import (
|
||||
FeishuCardMessage,
|
||||
FeishuCommandRequest,
|
||||
@@ -20,10 +19,11 @@ router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/webhook")
|
||||
def feishu_webhook(payload: dict[str, Any], db: Session = Depends(get_db)) -> dict:
|
||||
async def feishu_webhook(request: Request, db: Session = Depends(get_db)) -> dict:
|
||||
"""Handle Feishu webhook challenge and text command events."""
|
||||
|
||||
return FeishuEventService(db).handle_event(
|
||||
payload = FeishuWebhookVerifier().verify(await request.body(), request.headers)
|
||||
return FeishuEventService(db)._handle_verified_event(
|
||||
payload,
|
||||
source=FeishuEventSource.WEBHOOK,
|
||||
auto_reply=True,
|
||||
@@ -41,6 +41,7 @@ def send_text(
|
||||
receive_id=payload.receive_id,
|
||||
receive_id_type=payload.receive_id_type,
|
||||
actor=principal.actor,
|
||||
tenant_key=payload.tenant_key,
|
||||
)
|
||||
return {
|
||||
FeishuResponseKey.OK: result.get(FeishuPayloadKey.CODE) == 0,
|
||||
@@ -59,6 +60,7 @@ def send_card(
|
||||
receive_id=payload.receive_id,
|
||||
receive_id_type=payload.receive_id_type,
|
||||
actor=principal.actor,
|
||||
tenant_key=payload.tenant_key,
|
||||
)
|
||||
return {
|
||||
FeishuResponseKey.OK: result.get(FeishuPayloadKey.CODE) == 0,
|
||||
@@ -82,4 +84,5 @@ def preview_command(
|
||||
chat_id=payload.chat_id,
|
||||
actor=principal.actor,
|
||||
auto_reply=payload.auto_reply,
|
||||
tenant_key=payload.tenant_key,
|
||||
)
|
||||
|
||||
@@ -12,12 +12,14 @@ class FeishuTextMessage(BaseModel):
|
||||
description="chat_id or open_id depending on type.",
|
||||
)
|
||||
receive_id_type: str = FeishuReceiveIdType.CHAT_ID
|
||||
tenant_key: str | None = Field(default=None, max_length=128)
|
||||
text: str
|
||||
|
||||
|
||||
class FeishuCardMessage(BaseModel):
|
||||
receive_id: str | None = None
|
||||
receive_id_type: str = FeishuReceiveIdType.CHAT_ID
|
||||
tenant_key: str | None = Field(default=None, max_length=128)
|
||||
card: dict[str, Any]
|
||||
|
||||
|
||||
@@ -38,6 +40,7 @@ class FeishuSendResult(BaseModel):
|
||||
class FeishuCommandRequest(BaseModel):
|
||||
text: str
|
||||
chat_id: str | None = None
|
||||
tenant_key: str | None = Field(default=None, max_length=128)
|
||||
actor: str = ActorValue.API
|
||||
auto_reply: bool = False
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from hashlib import sha256
|
||||
from secrets import compare_digest
|
||||
from typing import Any
|
||||
|
||||
@@ -22,10 +23,18 @@ from app.modules.feishu.constants import (
|
||||
class FeishuService:
|
||||
"""Send Feishu messages and record audit entries for outbound actions."""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
def __init__(self, db: Session, tenant_key: str | None = None):
|
||||
self.db = db
|
||||
self.audit = AuditService(db)
|
||||
self.client = FeishuClient()
|
||||
self.client = FeishuClient(db)
|
||||
self.tenant_key = _optional_text(tenant_key) or _optional_text(
|
||||
get_settings().feishu_default_tenant_key
|
||||
)
|
||||
|
||||
def set_tenant_key(self, tenant_key: str | None) -> None:
|
||||
"""Set the default tenant used by subsequent outbound operations."""
|
||||
|
||||
self.tenant_key = _optional_text(tenant_key)
|
||||
|
||||
def verify_event(self, payload: dict[str, Any]) -> None:
|
||||
settings = get_settings()
|
||||
@@ -49,21 +58,32 @@ class FeishuService:
|
||||
receive_id: str | None = None,
|
||||
receive_id_type: str = FeishuReceiveIdType.CHAT_ID,
|
||||
actor: str = ActorValue.SYSTEM,
|
||||
uuid: str | None = None,
|
||||
tenant_key: str | None = None,
|
||||
record_audit: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
result = self.client.send_text(text, receive_id, receive_id_type)
|
||||
self.audit.log(
|
||||
AuditLogCreate(
|
||||
actor=actor,
|
||||
source=AuditSource.FEISHU,
|
||||
action=AuditAction.FEISHU_SEND_TEXT,
|
||||
request_payload={
|
||||
FeishuPayloadKey.RECEIVE_ID: receive_id,
|
||||
FeishuPayloadKey.RECEIVE_ID_TYPE: receive_id_type,
|
||||
FeishuPayloadKey.TEXT: text,
|
||||
},
|
||||
response_payload=result,
|
||||
)
|
||||
result = self.client.send_text(
|
||||
text,
|
||||
receive_id,
|
||||
receive_id_type,
|
||||
uuid,
|
||||
tenant_key=self._resolve_tenant_key(tenant_key),
|
||||
)
|
||||
if record_audit:
|
||||
self.audit.log(
|
||||
AuditLogCreate(
|
||||
actor=actor,
|
||||
source=AuditSource.FEISHU,
|
||||
action=AuditAction.FEISHU_SEND_TEXT,
|
||||
request_payload={
|
||||
"receive_target_hash": _target_fingerprint(receive_id),
|
||||
FeishuPayloadKey.RECEIVE_ID_TYPE: receive_id_type,
|
||||
"content_length": len(text),
|
||||
FeishuPayloadKey.UUID: uuid,
|
||||
},
|
||||
response_payload=result,
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
def send_card(
|
||||
@@ -72,17 +92,26 @@ class FeishuService:
|
||||
receive_id: str | None = None,
|
||||
receive_id_type: str = FeishuReceiveIdType.CHAT_ID,
|
||||
actor: str = ActorValue.SYSTEM,
|
||||
uuid: str | None = None,
|
||||
tenant_key: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
result = self.client.send_card(card, receive_id, receive_id_type)
|
||||
result = self.client.send_card(
|
||||
card,
|
||||
receive_id,
|
||||
receive_id_type,
|
||||
uuid,
|
||||
tenant_key=self._resolve_tenant_key(tenant_key),
|
||||
)
|
||||
self.audit.log(
|
||||
AuditLogCreate(
|
||||
actor=actor,
|
||||
source=AuditSource.FEISHU,
|
||||
action=AuditAction.FEISHU_SEND_CARD,
|
||||
request_payload={
|
||||
FeishuPayloadKey.RECEIVE_ID: receive_id,
|
||||
"receive_target_hash": _target_fingerprint(receive_id),
|
||||
FeishuPayloadKey.RECEIVE_ID_TYPE: receive_id_type,
|
||||
FeishuPayloadKey.CARD: card,
|
||||
"card_element_count": len(card.get(FeishuPayloadKey.ELEMENTS) or []),
|
||||
FeishuPayloadKey.UUID: uuid,
|
||||
},
|
||||
response_payload=result,
|
||||
)
|
||||
@@ -93,8 +122,12 @@ class FeishuService:
|
||||
self,
|
||||
image: bytes,
|
||||
actor: str = ActorValue.SYSTEM,
|
||||
tenant_key: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
result = self.client.upload_image(image)
|
||||
result = self.client.upload_image(
|
||||
image,
|
||||
tenant_key=self._resolve_tenant_key(tenant_key),
|
||||
)
|
||||
self.audit.log(
|
||||
AuditLogCreate(
|
||||
actor=actor,
|
||||
@@ -106,6 +139,9 @@ class FeishuService:
|
||||
)
|
||||
return result
|
||||
|
||||
def _resolve_tenant_key(self, tenant_key: str | None) -> str | None:
|
||||
return _optional_text(tenant_key) or self.tenant_key
|
||||
|
||||
@staticmethod
|
||||
def build_basic_card(
|
||||
title: str,
|
||||
@@ -144,3 +180,14 @@ class FeishuService:
|
||||
},
|
||||
FeishuPayloadKey.ELEMENTS: elements,
|
||||
}
|
||||
|
||||
|
||||
def _target_fingerprint(receive_id: str | None) -> str | None:
|
||||
if not receive_id:
|
||||
return None
|
||||
return sha256(receive_id.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
def _optional_text(value: str | None) -> str | None:
|
||||
text = str(value or "").strip()
|
||||
return text or None
|
||||
|
||||
22
app/modules/feishu_users/__init__.py
Normal file
22
app/modules/feishu_users/__init__.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from app.modules.feishu_users.constants import (
|
||||
FeishuCapability,
|
||||
FeishuUserRole,
|
||||
FeishuUserStatus,
|
||||
)
|
||||
from app.modules.feishu_users.models import FeishuUser
|
||||
from app.modules.feishu_users.principal import FeishuMention, FeishuPrincipal
|
||||
from app.modules.feishu_users.services import (
|
||||
FeishuIdentityService,
|
||||
FeishuUserManagementService,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"FeishuCapability",
|
||||
"FeishuIdentityService",
|
||||
"FeishuMention",
|
||||
"FeishuPrincipal",
|
||||
"FeishuUser",
|
||||
"FeishuUserManagementService",
|
||||
"FeishuUserRole",
|
||||
"FeishuUserStatus",
|
||||
]
|
||||
21
app/modules/feishu_users/bootstrap.py
Normal file
21
app/modules/feishu_users/bootstrap.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from hashlib import sha256
|
||||
|
||||
_ADMIN_BOOTSTRAP_TOMBSTONE_DOMAIN = (
|
||||
b"company-ai-platform:feishu-admin-bootstrap-tombstone:v1\0"
|
||||
)
|
||||
|
||||
|
||||
def admin_bootstrap_identity_hash(tenant_key: str, open_id: str) -> str:
|
||||
"""Return a domain-separated digest used only to prevent admin re-grants."""
|
||||
|
||||
tenant_bytes = tenant_key.encode("utf-8")
|
||||
open_id_bytes = open_id.encode("utf-8")
|
||||
identity = b"".join(
|
||||
(
|
||||
len(tenant_bytes).to_bytes(4, "big"),
|
||||
tenant_bytes,
|
||||
len(open_id_bytes).to_bytes(4, "big"),
|
||||
open_id_bytes,
|
||||
)
|
||||
)
|
||||
return sha256(_ADMIN_BOOTSTRAP_TOMBSTONE_DOMAIN + identity).hexdigest()
|
||||
86
app/modules/feishu_users/constants.py
Normal file
86
app/modules/feishu_users/constants.py
Normal file
@@ -0,0 +1,86 @@
|
||||
from enum import StrEnum
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
class FeishuUserRole(StrEnum):
|
||||
USER = "user"
|
||||
ADMIN = "admin"
|
||||
|
||||
|
||||
class FeishuUserStatus(StrEnum):
|
||||
ACTIVE = "active"
|
||||
DISABLED = "disabled"
|
||||
|
||||
|
||||
class FeishuCapability(StrEnum):
|
||||
PERSONAL_AI = "personal_ai"
|
||||
PERSONAL_DATA = "personal_data"
|
||||
PRIVATE_SUBSCRIPTION = "private_subscription"
|
||||
PERSONAL_MARKET = "personal_market"
|
||||
COMPANY_REPORTS = "company_reports"
|
||||
COMPANY_RULES = "company_rules"
|
||||
USER_ADMINISTRATION = "user_administration"
|
||||
GROUP_SUBSCRIPTION = "group_subscription"
|
||||
|
||||
|
||||
class FeishuUserAuditAction(StrEnum):
|
||||
REGISTER = "feishu.user.register"
|
||||
AUTHENTICATE = "feishu.user.authenticate"
|
||||
PERMISSION_DENIED = "feishu.permission.denied"
|
||||
LIST = "feishu.user.list"
|
||||
READ = "feishu.user.read"
|
||||
UPDATE = "feishu.user.update"
|
||||
UPDATE_DENIED = "feishu.user.update_denied"
|
||||
|
||||
|
||||
FEISHU_USER_CODE_PREFIX = "FSU"
|
||||
FEISHU_USER_TARGET_TYPE = "feishu-user"
|
||||
DEFAULT_FEISHU_USER_TIMEZONE = "Asia/Shanghai"
|
||||
LAST_ACTIVE_ADMIN_ERROR = "The last active Feishu administrator cannot be changed"
|
||||
FEISHU_USER_NOT_FOUND = "Feishu user not found"
|
||||
INVALID_FEISHU_IDENTITY = "tenant_key and open_id are required"
|
||||
INVALID_FEISHU_TIMEZONE = "Invalid IANA timezone"
|
||||
INVALID_QUIET_HOURS = "quiet_hours_start and quiet_hours_end must both be set or cleared"
|
||||
INVALID_ADMIN_IDENTITY = (
|
||||
"FEISHU_ADMIN_IDENTITIES entries must use tenant_key:open_id format"
|
||||
)
|
||||
|
||||
_USER_CAPABILITIES = frozenset(
|
||||
{
|
||||
FeishuCapability.PERSONAL_AI,
|
||||
FeishuCapability.PERSONAL_DATA,
|
||||
FeishuCapability.PRIVATE_SUBSCRIPTION,
|
||||
FeishuCapability.PERSONAL_MARKET,
|
||||
}
|
||||
)
|
||||
_ADMIN_CAPABILITIES = frozenset(FeishuCapability)
|
||||
|
||||
|
||||
def capabilities_for_role(role: str | FeishuUserRole) -> frozenset[FeishuCapability]:
|
||||
"""Return the fixed capability set for a Feishu user role."""
|
||||
|
||||
if FeishuUserRole(role) == FeishuUserRole.ADMIN:
|
||||
return _ADMIN_CAPABILITIES
|
||||
return _USER_CAPABILITIES
|
||||
|
||||
|
||||
def parse_admin_identities(
|
||||
value: str | Iterable[str] | None,
|
||||
) -> frozenset[tuple[str, str]]:
|
||||
"""Parse exact tenant/open-id pairs used only for initial administrator creation."""
|
||||
|
||||
if value is None:
|
||||
return frozenset()
|
||||
entries = value.split(",") if isinstance(value, str) else value
|
||||
identities: set[tuple[str, str]] = set()
|
||||
for entry in entries:
|
||||
text = str(entry).strip()
|
||||
if not text:
|
||||
continue
|
||||
tenant_key, separator, open_id = text.partition(":")
|
||||
tenant_key = tenant_key.strip()
|
||||
open_id = open_id.strip()
|
||||
if not separator or not tenant_key or not open_id:
|
||||
raise ValueError(INVALID_ADMIN_IDENTITY)
|
||||
identities.add((tenant_key, open_id))
|
||||
return frozenset(identities)
|
||||
63
app/modules/feishu_users/models.py
Normal file
63
app/modules/feishu_users/models.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from datetime import datetime, time
|
||||
|
||||
from sqlalchemy import DateTime, Integer, String, Time, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.database import Base
|
||||
from app.core.utils.time import utc_now
|
||||
from app.modules.feishu_users.constants import (
|
||||
DEFAULT_FEISHU_USER_TIMEZONE,
|
||||
FeishuUserRole,
|
||||
FeishuUserStatus,
|
||||
)
|
||||
|
||||
|
||||
class FeishuUser(Base):
|
||||
__tablename__ = "feishu_users"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_key",
|
||||
"open_id",
|
||||
name="uq_feishu_user_tenant_open_id",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
code: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
tenant_key: Mapped[str] = mapped_column(String(128), index=True)
|
||||
open_id: Mapped[str] = mapped_column(String(128), index=True)
|
||||
union_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
user_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
role: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
default=FeishuUserRole.USER,
|
||||
index=True,
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
default=FeishuUserStatus.ACTIVE,
|
||||
index=True,
|
||||
)
|
||||
timezone: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
default=DEFAULT_FEISHU_USER_TIMEZONE,
|
||||
)
|
||||
quiet_hours_start: Mapped[time | None] = mapped_column(Time, nullable=True)
|
||||
quiet_hours_end: Mapped[time | None] = mapped_column(Time, nullable=True)
|
||||
last_active_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
default=utc_now,
|
||||
onupdate=utc_now,
|
||||
)
|
||||
|
||||
|
||||
class FeishuAdminBootstrapTombstone(Base):
|
||||
"""Irreversible marker preventing a deleted initial admin from re-bootstrap."""
|
||||
|
||||
__tablename__ = "feishu_admin_bootstrap_tombstones"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
identity_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)
|
||||
102
app/modules/feishu_users/principal.py
Normal file
102
app/modules/feishu_users/principal.py
Normal file
@@ -0,0 +1,102 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import time
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from app.modules.feishu_users.constants import (
|
||||
FeishuCapability,
|
||||
FeishuUserRole,
|
||||
FeishuUserStatus,
|
||||
capabilities_for_role,
|
||||
)
|
||||
from app.modules.feishu_users.models import FeishuUser
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FeishuMention:
|
||||
"""Structured mention identity supplied by a verified Feishu event."""
|
||||
|
||||
key: str | None = None
|
||||
name: str | None = None
|
||||
tenant_key: str | None = None
|
||||
open_id: str | None = None
|
||||
union_id: str | None = None
|
||||
user_id: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FeishuPrincipal:
|
||||
"""Authenticated Feishu user plus the current chat context."""
|
||||
|
||||
owner_id: int
|
||||
user_code: str
|
||||
tenant_key: str
|
||||
open_id: str
|
||||
union_id: str | None
|
||||
feishu_user_id: str | None
|
||||
role: str
|
||||
status: str
|
||||
timezone: str
|
||||
quiet_hours_start: time | None
|
||||
quiet_hours_end: time | None
|
||||
chat_id: str | None = None
|
||||
chat_type: str | None = None
|
||||
mentions: tuple[FeishuMention, ...] = ()
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
return self.status == FeishuUserStatus.ACTIVE
|
||||
|
||||
@property
|
||||
def is_admin(self) -> bool:
|
||||
return self.role == FeishuUserRole.ADMIN
|
||||
|
||||
def has_capability(self, capability: str | FeishuCapability) -> bool:
|
||||
if not self.is_active:
|
||||
return False
|
||||
try:
|
||||
required = FeishuCapability(capability)
|
||||
return required in capabilities_for_role(self.role)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
def require_active(self) -> None:
|
||||
if not self.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Feishu user is disabled",
|
||||
)
|
||||
|
||||
def require_capability(self, capability: str | FeishuCapability) -> None:
|
||||
self.require_active()
|
||||
if not self.has_capability(capability):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Feishu user is not authorized for this capability",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_user(
|
||||
cls,
|
||||
user: FeishuUser,
|
||||
*,
|
||||
chat_id: str | None = None,
|
||||
chat_type: str | None = None,
|
||||
mentions: tuple[FeishuMention, ...] = (),
|
||||
) -> "FeishuPrincipal":
|
||||
return cls(
|
||||
owner_id=user.id,
|
||||
user_code=user.code,
|
||||
tenant_key=user.tenant_key,
|
||||
open_id=user.open_id,
|
||||
union_id=user.union_id,
|
||||
feishu_user_id=user.user_id,
|
||||
role=user.role,
|
||||
status=user.status,
|
||||
timezone=user.timezone,
|
||||
quiet_hours_start=user.quiet_hours_start,
|
||||
quiet_hours_end=user.quiet_hours_end,
|
||||
chat_id=chat_id,
|
||||
chat_type=chat_type,
|
||||
mentions=mentions,
|
||||
)
|
||||
80
app/modules/feishu_users/routes.py
Normal file
80
app/modules/feishu_users/routes.py
Normal file
@@ -0,0 +1,80 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.application.feishu.personal_data import FeishuPersonalDataService
|
||||
from app.core.database import get_db
|
||||
from app.core.security import ApiPrincipal, require_api_key
|
||||
from app.modules.feishu_users.constants import FeishuUserRole, FeishuUserStatus
|
||||
from app.modules.feishu_users.schemas import (
|
||||
FeishuUserListRead,
|
||||
FeishuUserRead,
|
||||
FeishuUserUpdate,
|
||||
)
|
||||
from app.modules.feishu_users.services import FeishuUserManagementService
|
||||
from app.modules.personalization.schemas import ErasureResult
|
||||
|
||||
router = APIRouter(prefix="/users", dependencies=[Depends(require_api_key)])
|
||||
|
||||
|
||||
@router.get("", response_model=FeishuUserListRead)
|
||||
def list_users(
|
||||
role: FeishuUserRole | None = None,
|
||||
status_filter: FeishuUserStatus | None = Query(default=None, alias="status"),
|
||||
tenant_key: str | None = None,
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
items, total = FeishuUserManagementService(db).list_users(
|
||||
role=role,
|
||||
status_filter=status_filter,
|
||||
tenant_key=tenant_key,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
actor=principal.actor,
|
||||
)
|
||||
return {
|
||||
"items": items,
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{code}", response_model=FeishuUserRead)
|
||||
def get_user(
|
||||
code: str,
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> FeishuUserRead:
|
||||
return FeishuUserManagementService(db).get_user(
|
||||
code,
|
||||
actor=principal.actor,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{code}", response_model=FeishuUserRead)
|
||||
def update_user(
|
||||
code: str,
|
||||
payload: FeishuUserUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> FeishuUserRead:
|
||||
return FeishuUserManagementService(db).update_user(
|
||||
code,
|
||||
changes=payload.model_dump(exclude_unset=True),
|
||||
actor=principal.actor,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{code}/personal-data", response_model=ErasureResult)
|
||||
def erase_user_personal_data(
|
||||
code: str,
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> ErasureResult:
|
||||
return FeishuPersonalDataService(db).erase_by_user_code(
|
||||
code,
|
||||
actor=principal.actor,
|
||||
)
|
||||
40
app/modules/feishu_users/schemas.py
Normal file
40
app/modules/feishu_users/schemas.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from datetime import datetime, time
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.modules.feishu_users.constants import FeishuUserRole, FeishuUserStatus
|
||||
|
||||
|
||||
class FeishuUserRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
code: str
|
||||
tenant_key: str
|
||||
open_id: str
|
||||
union_id: str | None
|
||||
user_id: str | None
|
||||
role: str
|
||||
status: str
|
||||
timezone: str
|
||||
quiet_hours_start: time | None
|
||||
quiet_hours_end: time | None
|
||||
last_active_at: datetime
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class FeishuUserListRead(BaseModel):
|
||||
items: list[FeishuUserRead]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class FeishuUserUpdate(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
role: FeishuUserRole | None = None
|
||||
status: FeishuUserStatus | None = None
|
||||
timezone: str | None = Field(default=None, min_length=1, max_length=64)
|
||||
quiet_hours_start: time | None = None
|
||||
quiet_hours_end: time | None = None
|
||||
9
app/modules/feishu_users/services/__init__.py
Normal file
9
app/modules/feishu_users/services/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from app.modules.feishu_users.services.identity import FeishuIdentityService
|
||||
from app.modules.feishu_users.services.management import (
|
||||
FeishuUserManagementService,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"FeishuIdentityService",
|
||||
"FeishuUserManagementService",
|
||||
]
|
||||
161
app/modules/feishu_users/services/identity.py
Normal file
161
app/modules/feishu_users/services/identity.py
Normal file
@@ -0,0 +1,161 @@
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import inspect, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.constants import ActorValue
|
||||
from app.core.utils.time import utc_now
|
||||
from app.modules.audit.constants import AuditRiskLevel, AuditSource
|
||||
from app.modules.audit.schemas import AuditLogCreate
|
||||
from app.modules.audit.service import AuditService
|
||||
from app.modules.feishu_users.constants import (
|
||||
FEISHU_USER_CODE_PREFIX,
|
||||
FEISHU_USER_TARGET_TYPE,
|
||||
INVALID_FEISHU_IDENTITY,
|
||||
FeishuUserAuditAction,
|
||||
FeishuUserRole,
|
||||
FeishuUserStatus,
|
||||
parse_admin_identities,
|
||||
)
|
||||
from app.modules.feishu_users.bootstrap import admin_bootstrap_identity_hash
|
||||
from app.modules.feishu_users.models import (
|
||||
FeishuAdminBootstrapTombstone,
|
||||
FeishuUser,
|
||||
)
|
||||
from app.modules.feishu_users.principal import FeishuPrincipal
|
||||
|
||||
|
||||
class FeishuIdentityService:
|
||||
"""Resolve or create identities only after the caller verifies the Feishu event."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db: Session,
|
||||
admin_identities: str | Iterable[str] | None = None,
|
||||
):
|
||||
self.db = db
|
||||
self.audit = AuditService(db)
|
||||
configured = (
|
||||
admin_identities
|
||||
if admin_identities is not None
|
||||
else getattr(get_settings(), "feishu_admin_identities", ())
|
||||
)
|
||||
self.admin_identities = parse_admin_identities(configured)
|
||||
|
||||
def resolve_or_register(
|
||||
self,
|
||||
*,
|
||||
tenant_key: str,
|
||||
open_id: str,
|
||||
union_id: str | None = None,
|
||||
user_id: str | None = None,
|
||||
actor: str = ActorValue.FEISHU,
|
||||
) -> FeishuPrincipal:
|
||||
"""Return a principal for a previously verified Feishu sender."""
|
||||
|
||||
tenant_key = _required_identity_part(tenant_key)
|
||||
open_id = _required_identity_part(open_id)
|
||||
union_id = _optional_identity_part(union_id)
|
||||
user_id = _optional_identity_part(user_id)
|
||||
record = self.get_by_identity(tenant_key=tenant_key, open_id=open_id)
|
||||
created = False
|
||||
if record is None:
|
||||
candidate = FeishuUser(
|
||||
code=f"{FEISHU_USER_CODE_PREFIX}-{uuid4().hex[:20].upper()}",
|
||||
tenant_key=tenant_key,
|
||||
open_id=open_id,
|
||||
union_id=union_id,
|
||||
user_id=user_id,
|
||||
role=self._initial_role(tenant_key, open_id),
|
||||
status=FeishuUserStatus.ACTIVE,
|
||||
last_active_at=utc_now(),
|
||||
)
|
||||
try:
|
||||
with self.db.begin_nested():
|
||||
self.db.add(candidate)
|
||||
self.db.flush()
|
||||
record = candidate
|
||||
created = True
|
||||
except IntegrityError:
|
||||
record = self.get_by_identity(tenant_key=tenant_key, open_id=open_id)
|
||||
if record is None:
|
||||
raise
|
||||
|
||||
record.last_active_at = utc_now()
|
||||
if union_id:
|
||||
record.union_id = union_id
|
||||
if user_id:
|
||||
record.user_id = user_id
|
||||
self.audit.record(
|
||||
AuditLogCreate(
|
||||
actor=actor,
|
||||
source=AuditSource.FEISHU,
|
||||
action=(
|
||||
FeishuUserAuditAction.REGISTER
|
||||
if created
|
||||
else FeishuUserAuditAction.AUTHENTICATE
|
||||
),
|
||||
target_type=FEISHU_USER_TARGET_TYPE,
|
||||
target_id=record.code,
|
||||
risk_level=AuditRiskLevel.LOW,
|
||||
response_payload={
|
||||
"created": created,
|
||||
"role": record.role,
|
||||
"status": record.status,
|
||||
},
|
||||
)
|
||||
)
|
||||
self.db.commit()
|
||||
self.db.refresh(record)
|
||||
if created and inspect(self.db.get_bind()).has_table("market_watchlists"):
|
||||
# Import locally so the identity domain does not create a module cycle.
|
||||
from app.modules.market.service import MarketService
|
||||
|
||||
MarketService(self.db).claim_legacy_watchlist(record.id, record.open_id)
|
||||
return FeishuPrincipal.from_user(record)
|
||||
|
||||
def get_by_identity(self, *, tenant_key: str, open_id: str) -> FeishuUser | None:
|
||||
return self.db.execute(
|
||||
select(FeishuUser).where(
|
||||
FeishuUser.tenant_key == tenant_key,
|
||||
FeishuUser.open_id == open_id,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
def get_by_code(self, code: str) -> FeishuUser | None:
|
||||
return self.db.execute(
|
||||
select(FeishuUser).where(FeishuUser.code == code)
|
||||
).scalar_one_or_none()
|
||||
|
||||
def _initial_role(self, tenant_key: str, open_id: str) -> str:
|
||||
if (tenant_key, open_id) not in self.admin_identities:
|
||||
return FeishuUserRole.USER
|
||||
identity_hash = admin_bootstrap_identity_hash(tenant_key, open_id)
|
||||
was_erased = self.db.scalar(
|
||||
select(FeishuAdminBootstrapTombstone.id)
|
||||
.where(
|
||||
FeishuAdminBootstrapTombstone.identity_hash == identity_hash
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
return FeishuUserRole.USER if was_erased is not None else FeishuUserRole.ADMIN
|
||||
|
||||
|
||||
def _required_identity_part(value: Any) -> str:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=INVALID_FEISHU_IDENTITY,
|
||||
)
|
||||
return text
|
||||
|
||||
|
||||
def _optional_identity_part(value: Any) -> str | None:
|
||||
text = str(value or "").strip()
|
||||
return text or None
|
||||
290
app/modules/feishu_users/services/management.py
Normal file
290
app/modules/feishu_users/services/management.py
Normal file
@@ -0,0 +1,290 @@
|
||||
from datetime import time
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.constants import ActorValue
|
||||
from app.modules.audit.constants import AuditRiskLevel, AuditSource
|
||||
from app.modules.audit.schemas import AuditLogCreate
|
||||
from app.modules.audit.service import AuditService
|
||||
from app.modules.feishu_users.constants import (
|
||||
FEISHU_USER_NOT_FOUND,
|
||||
FEISHU_USER_TARGET_TYPE,
|
||||
INVALID_FEISHU_TIMEZONE,
|
||||
INVALID_QUIET_HOURS,
|
||||
LAST_ACTIVE_ADMIN_ERROR,
|
||||
FeishuUserAuditAction,
|
||||
FeishuUserRole,
|
||||
FeishuUserStatus,
|
||||
)
|
||||
from app.modules.feishu_users.models import FeishuUser
|
||||
|
||||
_UPDATABLE_FIELDS = frozenset(
|
||||
{
|
||||
"role",
|
||||
"status",
|
||||
"timezone",
|
||||
"quiet_hours_start",
|
||||
"quiet_hours_end",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class FeishuUserManagementService:
|
||||
"""Manage Feishu users while preserving an active administrator."""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.audit = AuditService(db)
|
||||
|
||||
def list_users(
|
||||
self,
|
||||
*,
|
||||
role: str | None = None,
|
||||
status_filter: str | None = None,
|
||||
tenant_key: str | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
actor: str | None = None,
|
||||
) -> tuple[list[FeishuUser], int]:
|
||||
filters = []
|
||||
if role:
|
||||
filters.append(FeishuUser.role == FeishuUserRole(role))
|
||||
if status_filter:
|
||||
filters.append(FeishuUser.status == FeishuUserStatus(status_filter))
|
||||
if tenant_key:
|
||||
filters.append(FeishuUser.tenant_key == tenant_key)
|
||||
total = int(
|
||||
self.db.scalar(
|
||||
select(func.count()).select_from(FeishuUser).where(*filters)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
items = list(
|
||||
self.db.execute(
|
||||
select(FeishuUser)
|
||||
.where(*filters)
|
||||
.order_by(FeishuUser.created_at.desc(), FeishuUser.id.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
).scalars()
|
||||
)
|
||||
if actor:
|
||||
self._audit_read(
|
||||
actor=actor,
|
||||
action=FeishuUserAuditAction.LIST,
|
||||
response_payload={"count": len(items), "total": total},
|
||||
)
|
||||
return items, total
|
||||
|
||||
def get_user(self, code: str, *, actor: str | None = None) -> FeishuUser:
|
||||
record = self._find_user(code)
|
||||
if actor:
|
||||
self._audit_read(
|
||||
actor=actor,
|
||||
action=FeishuUserAuditAction.READ,
|
||||
target_id=record.code,
|
||||
)
|
||||
return record
|
||||
|
||||
def update_user(
|
||||
self,
|
||||
code: str,
|
||||
*,
|
||||
changes: dict[str, Any],
|
||||
actor: str = ActorValue.API,
|
||||
) -> FeishuUser:
|
||||
unexpected = set(changes) - _UPDATABLE_FIELDS
|
||||
if unexpected:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Unsupported Feishu user fields: {', '.join(sorted(unexpected))}",
|
||||
)
|
||||
active_admin_ids = (
|
||||
self._active_admin_ids()
|
||||
if {"role", "status"} & changes.keys()
|
||||
else []
|
||||
)
|
||||
record = self.db.execute(
|
||||
select(FeishuUser)
|
||||
.where(FeishuUser.code == code)
|
||||
.with_for_update()
|
||||
).scalar_one_or_none()
|
||||
if record is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=FEISHU_USER_NOT_FOUND,
|
||||
)
|
||||
if not changes:
|
||||
return record
|
||||
|
||||
normalized = self._normalized_changes(record, changes)
|
||||
proposed_role = normalized.get("role", record.role)
|
||||
proposed_status = normalized.get("status", record.status)
|
||||
removes_active_admin = (
|
||||
record.role == FeishuUserRole.ADMIN
|
||||
and record.status == FeishuUserStatus.ACTIVE
|
||||
and (
|
||||
proposed_role != FeishuUserRole.ADMIN
|
||||
or proposed_status != FeishuUserStatus.ACTIVE
|
||||
)
|
||||
)
|
||||
if removes_active_admin and active_admin_ids == [record.id]:
|
||||
self.audit.record(
|
||||
AuditLogCreate(
|
||||
actor=actor,
|
||||
source=AuditSource.API,
|
||||
action=FeishuUserAuditAction.UPDATE_DENIED,
|
||||
target_type=FEISHU_USER_TARGET_TYPE,
|
||||
target_id=record.code,
|
||||
risk_level=AuditRiskLevel.HIGH,
|
||||
request_payload={
|
||||
"role": proposed_role,
|
||||
"status": proposed_status,
|
||||
},
|
||||
response_payload={
|
||||
"result": "denied",
|
||||
"reason": "last_active_admin",
|
||||
},
|
||||
status="denied",
|
||||
)
|
||||
)
|
||||
self.db.commit()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=LAST_ACTIVE_ADMIN_ERROR,
|
||||
)
|
||||
|
||||
before = _auditable_state(record)
|
||||
for field, value in normalized.items():
|
||||
setattr(record, field, value)
|
||||
after = _auditable_state(record)
|
||||
self.audit.record(
|
||||
AuditLogCreate(
|
||||
actor=actor,
|
||||
source=AuditSource.API,
|
||||
action=FeishuUserAuditAction.UPDATE,
|
||||
target_type=FEISHU_USER_TARGET_TYPE,
|
||||
target_id=record.code,
|
||||
risk_level=AuditRiskLevel.HIGH,
|
||||
request_payload={"before": before, "after": after},
|
||||
response_payload={"updated_fields": sorted(normalized)},
|
||||
)
|
||||
)
|
||||
self.db.commit()
|
||||
self.db.refresh(record)
|
||||
return record
|
||||
|
||||
def _find_user(self, code: str) -> FeishuUser:
|
||||
record = self.db.execute(
|
||||
select(FeishuUser).where(FeishuUser.code == code)
|
||||
).scalar_one_or_none()
|
||||
if record is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=FEISHU_USER_NOT_FOUND,
|
||||
)
|
||||
return record
|
||||
|
||||
def _normalized_changes(
|
||||
self,
|
||||
record: FeishuUser,
|
||||
changes: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
normalized = dict(changes)
|
||||
if "role" in normalized:
|
||||
if normalized["role"] is None:
|
||||
raise _unprocessable("role cannot be null")
|
||||
normalized["role"] = FeishuUserRole(normalized["role"])
|
||||
if "status" in normalized:
|
||||
if normalized["status"] is None:
|
||||
raise _unprocessable("status cannot be null")
|
||||
normalized["status"] = FeishuUserStatus(normalized["status"])
|
||||
if "timezone" in normalized:
|
||||
timezone = str(normalized["timezone"] or "").strip()
|
||||
if not timezone:
|
||||
raise _unprocessable(INVALID_FEISHU_TIMEZONE)
|
||||
try:
|
||||
ZoneInfo(timezone)
|
||||
except ZoneInfoNotFoundError as exc:
|
||||
raise _unprocessable(INVALID_FEISHU_TIMEZONE) from exc
|
||||
normalized["timezone"] = timezone
|
||||
for field in ("quiet_hours_start", "quiet_hours_end"):
|
||||
if field in normalized:
|
||||
normalized[field] = _optional_time(normalized[field])
|
||||
quiet_start = normalized.get("quiet_hours_start", record.quiet_hours_start)
|
||||
quiet_end = normalized.get("quiet_hours_end", record.quiet_hours_end)
|
||||
if (quiet_start is None) != (quiet_end is None):
|
||||
raise _unprocessable(INVALID_QUIET_HOURS)
|
||||
return normalized
|
||||
|
||||
def _active_admin_ids(self) -> list[int]:
|
||||
return list(
|
||||
self.db.execute(
|
||||
select(FeishuUser.id)
|
||||
.where(
|
||||
FeishuUser.role == FeishuUserRole.ADMIN,
|
||||
FeishuUser.status == FeishuUserStatus.ACTIVE,
|
||||
)
|
||||
.order_by(FeishuUser.id.asc())
|
||||
.with_for_update()
|
||||
).scalars()
|
||||
)
|
||||
|
||||
def _audit_read(
|
||||
self,
|
||||
*,
|
||||
actor: str,
|
||||
action: str,
|
||||
target_id: str | None = None,
|
||||
response_payload: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
self.audit.record(
|
||||
AuditLogCreate(
|
||||
actor=actor,
|
||||
source=AuditSource.API,
|
||||
action=action,
|
||||
target_type=FEISHU_USER_TARGET_TYPE,
|
||||
target_id=target_id,
|
||||
risk_level=AuditRiskLevel.LOW,
|
||||
response_payload=response_payload,
|
||||
)
|
||||
)
|
||||
self.db.commit()
|
||||
|
||||
|
||||
def _auditable_state(record: FeishuUser) -> dict[str, Any]:
|
||||
return {
|
||||
"role": record.role,
|
||||
"status": record.status,
|
||||
"timezone": record.timezone,
|
||||
"quiet_hours_start": (
|
||||
record.quiet_hours_start.isoformat()
|
||||
if record.quiet_hours_start is not None
|
||||
else None
|
||||
),
|
||||
"quiet_hours_end": (
|
||||
record.quiet_hours_end.isoformat()
|
||||
if record.quiet_hours_end is not None
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _optional_time(value: Any) -> time | None:
|
||||
if value is None or isinstance(value, time):
|
||||
return value
|
||||
try:
|
||||
return time.fromisoformat(str(value))
|
||||
except ValueError as exc:
|
||||
raise _unprocessable("Invalid quiet-hours time") from exc
|
||||
|
||||
|
||||
def _unprocessable(detail: str) -> HTTPException:
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=detail,
|
||||
)
|
||||
@@ -75,6 +75,8 @@ class LegacyQueryError(StrEnum):
|
||||
PROJECT_QUERY_NOT_CONFIGURED = "LEGACY_PROJECT_QUERY is not configured. Configure it first."
|
||||
TASK_QUERY_NOT_CONFIGURED = "LEGACY_TASK_QUERY is not configured. Configure it first."
|
||||
ONLY_SELECT_ALLOWED = "Only SELECT statements are allowed"
|
||||
SQL_COMMENTS_NOT_ALLOWED = "SQL comments are not allowed in readonly queries"
|
||||
SINGLE_STATEMENT_REQUIRED = "Only one SQL statement is allowed"
|
||||
FORBIDDEN_SQL_TOKEN = "Forbidden SQL token in readonly query"
|
||||
INVALID_LIMIT = "Invalid readonly query limit"
|
||||
APP_DB_UNAVAILABLE = "Application database session is not available"
|
||||
|
||||
@@ -4,7 +4,7 @@ from sqlalchemy.orm import Session
|
||||
from app.core.background.task_queue import enqueue_legacy_project_sync, enqueue_legacy_task_sync
|
||||
from app.core.database import get_db
|
||||
from app.core.http.masking import mask_configured
|
||||
from app.core.security import ApiPrincipal, require_api_key, require_operations_enabled
|
||||
from app.core.security import ApiPrincipal, require_api_key
|
||||
from app.modules.legacy_mysql.schemas import (
|
||||
LegacyProjectSyncRequest,
|
||||
LegacyProjectSyncResult,
|
||||
@@ -60,7 +60,6 @@ def sync_projects(
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
require_operations_enabled()
|
||||
result = LegacyMySQLService(db).sync_projects(
|
||||
source_query=payload.source_query,
|
||||
source_query_name=payload.source_query_name,
|
||||
@@ -77,7 +76,6 @@ def enqueue_sync_projects(
|
||||
payload: LegacyProjectSyncRequest,
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
require_operations_enabled()
|
||||
return enqueue_legacy_project_sync(
|
||||
source_query=payload.source_query,
|
||||
source_query_name=payload.source_query_name,
|
||||
@@ -94,7 +92,6 @@ def sync_tasks(
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
require_operations_enabled()
|
||||
result = LegacyMySQLService(db).sync_tasks(
|
||||
source_query=payload.source_query,
|
||||
source_query_name=payload.source_query_name,
|
||||
@@ -111,7 +108,6 @@ def enqueue_sync_tasks(
|
||||
payload: LegacyTaskSyncRequest,
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
require_operations_enabled()
|
||||
return enqueue_legacy_task_sync(
|
||||
source_query=payload.source_query,
|
||||
source_query_name=payload.source_query_name,
|
||||
|
||||
@@ -7,6 +7,8 @@ from sqlalchemy.engine import RowMapping
|
||||
from app.modules.legacy_mysql.constants import LEGACY_SQL_TRAILING_TERMINATOR, LegacyQueryName
|
||||
|
||||
FORBIDDEN_SQL_TOKENS = {
|
||||
"benchmark",
|
||||
"call",
|
||||
"insert",
|
||||
"update",
|
||||
"delete",
|
||||
@@ -14,9 +16,21 @@ FORBIDDEN_SQL_TOKENS = {
|
||||
"alter",
|
||||
"truncate",
|
||||
"create",
|
||||
"do",
|
||||
"dumpfile",
|
||||
"execute",
|
||||
"replace",
|
||||
"grant",
|
||||
"get_lock",
|
||||
"handler",
|
||||
"into",
|
||||
"load_file",
|
||||
"lock",
|
||||
"outfile",
|
||||
"release_lock",
|
||||
"revoke",
|
||||
"set",
|
||||
"sleep",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.constants import ActorValue
|
||||
from app.core.security import ensure_business_mutations_enabled
|
||||
from app.core.utils.time import utc_now
|
||||
from app.modules.audit.constants import AuditAction, AuditRiskLevel, AuditSource, AuditStatus
|
||||
from app.modules.audit.schemas import AuditLogCreate
|
||||
@@ -44,7 +43,6 @@ class LegacyProjectSyncMixin:
|
||||
dry_run: bool = True,
|
||||
actor: str = ActorValue.API,
|
||||
) -> dict[str, Any]:
|
||||
ensure_business_mutations_enabled()
|
||||
if self.db is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
@@ -23,6 +24,13 @@ from app.modules.legacy_mysql.constants import (
|
||||
|
||||
from app.modules.legacy_mysql.services.common import FORBIDDEN_SQL_TOKENS, _normalize_sql, _query_name_text, _row_to_dict
|
||||
|
||||
_SQL_COMMENT_MARKERS = ("--", "#", "/*", "*/")
|
||||
_SQL_QUOTED_CONTENT_PATTERN = re.compile(
|
||||
r"""'(?:''|\\.|[^'])*'|"(?:""|\\.|[^"])*"|`(?:``|[^`])*`""",
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
_SQL_WORD_PATTERN = re.compile(r"[a-z_]+")
|
||||
|
||||
|
||||
class LegacyQueryMixin:
|
||||
@staticmethod
|
||||
@@ -36,13 +44,27 @@ class LegacyQueryMixin:
|
||||
|
||||
@staticmethod
|
||||
def _ensure_readonly(sql: str) -> None:
|
||||
stripped = sql.strip().lower()
|
||||
if not stripped.startswith(LEGACY_SELECT_PREFIX):
|
||||
stripped = sql.strip()
|
||||
scrubbed = _SQL_QUOTED_CONTENT_PATTERN.sub(" ", stripped)
|
||||
if any(marker in scrubbed for marker in _SQL_COMMENT_MARKERS):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=LegacyQueryError.SQL_COMMENTS_NOT_ALLOWED,
|
||||
)
|
||||
statement = scrubbed.rstrip()
|
||||
if statement.endswith(LEGACY_SQL_TRAILING_TERMINATOR):
|
||||
statement = statement[:-1].rstrip()
|
||||
if LEGACY_SQL_TRAILING_TERMINATOR in statement:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=LegacyQueryError.SINGLE_STATEMENT_REQUIRED,
|
||||
)
|
||||
if not re.match(rf"^{LEGACY_SELECT_PREFIX}\b", statement, flags=re.IGNORECASE):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=LegacyQueryError.ONLY_SELECT_ALLOWED,
|
||||
)
|
||||
tokens = {token.strip(" ;,\n\t") for token in stripped.replace("(", " ").split()}
|
||||
tokens = set(_SQL_WORD_PATTERN.findall(statement.lower()))
|
||||
if tokens & FORBIDDEN_SQL_TOKENS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -117,9 +139,10 @@ class LegacyQueryMixin:
|
||||
engine = self._ensure_engine()
|
||||
params = dict(params or {})
|
||||
try:
|
||||
params[LegacyResponseKey.LIMIT] = bounded_limit(
|
||||
limit_value = bounded_limit(
|
||||
params.get(LegacyResponseKey.LIMIT, limit)
|
||||
)
|
||||
params[LegacyResponseKey.LIMIT] = limit_value
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
@@ -130,7 +153,10 @@ class LegacyQueryMixin:
|
||||
limited_sql = f"{sql.rstrip(LEGACY_SQL_TRAILING_TERMINATOR)}{LEGACY_LIMIT_CLAUSE}"
|
||||
with engine.connect() as conn:
|
||||
result = conn.execute(text(limited_sql), params)
|
||||
rows = [_row_to_dict(row) for row in result.mappings().all()]
|
||||
rows = [
|
||||
_row_to_dict(row)
|
||||
for row in result.mappings().fetchmany(limit_value)
|
||||
]
|
||||
columns = list(rows[0].keys()) if rows else []
|
||||
return {
|
||||
LegacyResponseKey.COLUMNS: columns,
|
||||
|
||||
@@ -4,7 +4,6 @@ from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.constants import ActorValue
|
||||
from app.core.security import ensure_business_mutations_enabled
|
||||
from app.core.utils.time import utc_now
|
||||
from app.modules.audit.constants import AuditAction, AuditRiskLevel, AuditSource, AuditStatus
|
||||
from app.modules.audit.schemas import AuditLogCreate
|
||||
@@ -44,7 +43,6 @@ class LegacyTaskSyncMixin:
|
||||
dry_run: bool = True,
|
||||
actor: str = ActorValue.API,
|
||||
) -> dict[str, Any]:
|
||||
ensure_business_mutations_enabled()
|
||||
if self.db is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
|
||||
@@ -7,7 +7,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.background.task_queue.market import enqueue_market_report
|
||||
from app.core.security import ApiPrincipal, require_api_key, require_operations_enabled
|
||||
from app.core.security import ApiPrincipal, require_api_key
|
||||
from app.modules.market.service import MarketService
|
||||
|
||||
router = APIRouter(dependencies=[Depends(require_api_key)])
|
||||
@@ -89,13 +89,11 @@ def announcements(
|
||||
|
||||
@router.post("/sync/daily")
|
||||
def sync_daily(trade_date: date, db: Session = Depends(get_db)) -> dict:
|
||||
require_operations_enabled()
|
||||
return MarketService(db).sync_daily(trade_date)
|
||||
|
||||
|
||||
@router.post("/sync/macro")
|
||||
def sync_macro(reference_date: date | None = None, db: Session = Depends(get_db)) -> dict:
|
||||
require_operations_enabled()
|
||||
return MarketService(db).sync_macro(reference_date)
|
||||
|
||||
|
||||
@@ -103,13 +101,11 @@ def sync_macro(reference_date: date | None = None, db: Session = Depends(get_db)
|
||||
def sync_announcements(
|
||||
start_date: date, end_date: date, db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
require_operations_enabled()
|
||||
return {"processed": MarketService(db).sync_announcements(start_date, end_date)}
|
||||
|
||||
|
||||
@router.post("/reports/enqueue")
|
||||
def enqueue_report(payload: MarketReportRequest) -> dict:
|
||||
require_operations_enabled()
|
||||
return enqueue_market_report(payload.report_type, payload.reference_date, payload.force)
|
||||
|
||||
|
||||
@@ -119,7 +115,6 @@ def add_watchlist(
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
require_operations_enabled()
|
||||
return MarketService(db).add_watchlist(principal.actor, payload.symbol)
|
||||
|
||||
|
||||
|
||||
@@ -780,15 +780,31 @@ class MarketService:
|
||||
report["content"] = "\n".join(lines)
|
||||
return report
|
||||
|
||||
def add_watchlist(self, actor: str, symbol: str) -> dict[str, Any]:
|
||||
def add_watchlist(
|
||||
self,
|
||||
actor: str,
|
||||
symbol: str,
|
||||
owner_id: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
code = normalize_symbol(symbol)
|
||||
owner_clause = (
|
||||
MarketWatchlist.owner_id.is_(None)
|
||||
if owner_id is None
|
||||
else MarketWatchlist.owner_id == owner_id
|
||||
)
|
||||
record = self.db.execute(
|
||||
select(MarketWatchlist).where(
|
||||
MarketWatchlist.actor == actor, MarketWatchlist.symbol == code
|
||||
owner_clause,
|
||||
MarketWatchlist.symbol == code,
|
||||
*(
|
||||
(MarketWatchlist.actor == actor,)
|
||||
if owner_id is None
|
||||
else ()
|
||||
),
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if record is None:
|
||||
record = MarketWatchlist(actor=actor, symbol=code)
|
||||
record = MarketWatchlist(owner_id=owner_id, actor=actor, symbol=code)
|
||||
self.db.add(record)
|
||||
else:
|
||||
record.enabled = True
|
||||
@@ -804,16 +820,77 @@ class MarketService:
|
||||
response_payload={"enabled": True},
|
||||
)
|
||||
)
|
||||
return {"actor": actor, "symbol": code, "enabled": True}
|
||||
return {
|
||||
"actor": actor,
|
||||
"owner_id": owner_id,
|
||||
"symbol": code,
|
||||
"enabled": True,
|
||||
}
|
||||
|
||||
def watchlist(self, actor: str) -> list[dict[str, Any]]:
|
||||
def watchlist(
|
||||
self,
|
||||
actor: str,
|
||||
owner_id: int | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
owner_clause = (
|
||||
MarketWatchlist.owner_id.is_(None)
|
||||
if owner_id is None
|
||||
else MarketWatchlist.owner_id == owner_id
|
||||
)
|
||||
records = self.db.execute(
|
||||
select(MarketWatchlist).where(
|
||||
MarketWatchlist.actor == actor, MarketWatchlist.enabled.is_(True)
|
||||
owner_clause,
|
||||
MarketWatchlist.enabled.is_(True),
|
||||
*(
|
||||
(MarketWatchlist.actor == actor,)
|
||||
if owner_id is None
|
||||
else ()
|
||||
),
|
||||
)
|
||||
).scalars()
|
||||
return [{"symbol": r.symbol} for r in records]
|
||||
|
||||
def claim_legacy_watchlist(self, owner_id: int, open_id: str) -> int:
|
||||
"""Claim still-unowned rows created by the verified legacy Feishu actor."""
|
||||
|
||||
legacy_records = list(
|
||||
self.db.execute(
|
||||
select(MarketWatchlist).where(
|
||||
MarketWatchlist.owner_id.is_(None),
|
||||
MarketWatchlist.actor == open_id,
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
claimed = 0
|
||||
for legacy in legacy_records:
|
||||
existing = self.db.execute(
|
||||
select(MarketWatchlist).where(
|
||||
MarketWatchlist.owner_id == owner_id,
|
||||
MarketWatchlist.symbol == legacy.symbol,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
existing.enabled = existing.enabled or legacy.enabled
|
||||
self.db.delete(legacy)
|
||||
continue
|
||||
legacy.owner_id = owner_id
|
||||
claimed += 1
|
||||
self.db.commit()
|
||||
return claimed
|
||||
|
||||
def delete_owner_watchlist(self, owner_id: int) -> int:
|
||||
"""Stage deletion of all personal watchlist rows for an owner."""
|
||||
|
||||
records = list(
|
||||
self.db.execute(
|
||||
select(MarketWatchlist).where(MarketWatchlist.owner_id == owner_id)
|
||||
).scalars()
|
||||
)
|
||||
for record in records:
|
||||
self.db.delete(record)
|
||||
self.db.flush()
|
||||
return len(records)
|
||||
|
||||
def _ai(self, skill: AISkillId, report: dict[str, Any], actor: str) -> dict[str, Any]:
|
||||
try:
|
||||
result = AIService(self.db).run_skill(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Integer, String
|
||||
from sqlalchemy import DateTime, Integer, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.database import Base
|
||||
@@ -9,6 +9,9 @@ from app.core.utils.time import utc_now
|
||||
|
||||
class SystemHeartbeat(Base):
|
||||
__tablename__ = "system_heartbeats"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("component", "instance_id", name="uq_system_heartbeat_component_instance"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
component: Mapped[str] = mapped_column(String(128), index=True)
|
||||
|
||||
@@ -32,4 +32,6 @@ def ready(
|
||||
def metrics(
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
return ObservabilityService(db).metrics()
|
||||
result = ObservabilityService(db).metrics()
|
||||
db.commit()
|
||||
return result
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
from collections.abc import Callable
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy import and_, func, or_, select, text
|
||||
from sqlalchemy.dialects.postgresql import insert as postgresql_insert
|
||||
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
@@ -18,6 +21,10 @@ from app.modules.audit.schemas import AuditLogCreate
|
||||
from app.modules.audit.service import AuditService
|
||||
from app.modules.events.constants import EventStatus
|
||||
from app.modules.events.services import EventService
|
||||
from app.modules.feishu.app_tickets import FeishuAppTicketService
|
||||
from app.modules.feishu.constants import FeishuAppType
|
||||
from app.modules.feishu_users.constants import FeishuUserStatus
|
||||
from app.modules.feishu_users.models import FeishuUser
|
||||
from app.modules.observability.constants import (
|
||||
HeartbeatStatus,
|
||||
ObservabilityKey,
|
||||
@@ -27,6 +34,11 @@ from app.modules.observability.constants import (
|
||||
from app.modules.observability.models import SystemHeartbeat
|
||||
from app.modules.workflows.constants import WorkflowStatus
|
||||
from app.modules.workflows.service import WorkflowService
|
||||
from app.modules.subscriptions.constants import (
|
||||
PushDeliveryStatus,
|
||||
PushSubscriptionStatus,
|
||||
)
|
||||
from app.modules.subscriptions.models import PushDelivery, PushSubscription
|
||||
|
||||
|
||||
class ObservabilityService:
|
||||
@@ -40,31 +52,42 @@ class ObservabilityService:
|
||||
|
||||
def ready(self) -> dict[str, Any]:
|
||||
checks = {
|
||||
ObservabilityKey.DATABASE: self._database_check(),
|
||||
ObservabilityKey.REDIS: self._redis_check(),
|
||||
ObservabilityKey.EVENTS: self._events_check(),
|
||||
ObservabilityKey.WORKFLOWS: self._workflows_check(),
|
||||
ObservabilityKey.HEARTBEATS: self._heartbeats_check(),
|
||||
}
|
||||
degraded = any(
|
||||
item[ObservabilityKey.STATUS]
|
||||
in {ObservabilityStatus.DEGRADED, ObservabilityStatus.ERROR}
|
||||
for item in checks.values()
|
||||
)
|
||||
return {
|
||||
ObservabilityKey.STATUS: (
|
||||
ObservabilityStatus.DEGRADED if degraded else ObservabilityStatus.OK
|
||||
ObservabilityKey.DATABASE: self._safe_call(self._database_check),
|
||||
ObservabilityKey.REDIS: self._safe_call(self._redis_check),
|
||||
ObservabilityKey.EVENTS: self._safe_call(self._events_check),
|
||||
ObservabilityKey.WORKFLOWS: self._safe_call(self._workflows_check),
|
||||
ObservabilityKey.HEARTBEATS: self._safe_call(self._heartbeats_check),
|
||||
"feishu_subscriptions": self._safe_call(
|
||||
self._feishu_subscriptions_check
|
||||
),
|
||||
}
|
||||
statuses = {item[ObservabilityKey.STATUS] for item in checks.values()}
|
||||
if statuses & {ObservabilityStatus.ERROR, ObservabilityStatus.DEGRADED}:
|
||||
overall_status = ObservabilityStatus.DEGRADED
|
||||
else:
|
||||
overall_status = ObservabilityStatus.OK
|
||||
return {
|
||||
ObservabilityKey.STATUS: overall_status,
|
||||
ObservabilityKey.CHECKS: checks,
|
||||
}
|
||||
|
||||
def metrics(self) -> dict[str, Any]:
|
||||
return {
|
||||
ObservabilityKey.METRICS: {
|
||||
ObservabilityKey.EVENTS: EventService(self.db).count_by_status(),
|
||||
ObservabilityKey.WORKFLOWS: WorkflowService(self.db).count_by_status(),
|
||||
ObservabilityKey.AI_MEMORY: AIMemoryService(self.db).count_by_status(),
|
||||
ObservabilityKey.HEARTBEATS: self.heartbeat_summary(),
|
||||
ObservabilityKey.EVENTS: self._safe_call(
|
||||
lambda: EventService(self.db).count_by_status()
|
||||
),
|
||||
ObservabilityKey.WORKFLOWS: self._safe_call(
|
||||
lambda: WorkflowService(self.db).count_by_status()
|
||||
),
|
||||
ObservabilityKey.AI_MEMORY: self._safe_call(
|
||||
lambda: AIMemoryService(self.db).count_by_status()
|
||||
),
|
||||
ObservabilityKey.HEARTBEATS: self._safe_call(
|
||||
self.heartbeat_summary
|
||||
),
|
||||
"feishu_users": self._safe_call(self._feishu_user_metrics),
|
||||
"subscriptions": self._safe_call(self._subscription_metrics),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,24 +99,7 @@ class ObservabilityService:
|
||||
actor: str = ActorValue.SYSTEM,
|
||||
) -> dict[str, Any]:
|
||||
now = utc_now()
|
||||
record = self.db.execute(
|
||||
select(SystemHeartbeat).where(
|
||||
SystemHeartbeat.component == component,
|
||||
SystemHeartbeat.instance_id == instance_id,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if record is None:
|
||||
record = SystemHeartbeat(
|
||||
component=component,
|
||||
instance_id=instance_id,
|
||||
status=status_value,
|
||||
last_seen_at=now,
|
||||
)
|
||||
self.db.add(record)
|
||||
else:
|
||||
record.status = status_value
|
||||
record.last_seen_at = now
|
||||
record.updated_at = now
|
||||
record = self._upsert_heartbeat(component, instance_id, status_value, now)
|
||||
AuditService(self.db).record(
|
||||
AuditLogCreate(
|
||||
actor=actor,
|
||||
@@ -115,7 +121,13 @@ class ObservabilityService:
|
||||
return self._serialize_heartbeat(record)
|
||||
|
||||
def heartbeat_summary(self) -> dict[str, Any]:
|
||||
records = list(self.db.execute(select(SystemHeartbeat)).scalars())
|
||||
records = list(
|
||||
self.db.execute(
|
||||
select(SystemHeartbeat)
|
||||
.where(SystemHeartbeat.last_seen_at >= self._heartbeat_retention_threshold())
|
||||
.order_by(SystemHeartbeat.component.asc(), SystemHeartbeat.instance_id.asc())
|
||||
).scalars()
|
||||
)
|
||||
threshold = self._heartbeat_stale_threshold()
|
||||
stale = [item for item in records if item.last_seen_at < threshold]
|
||||
active = len(records) - len(stale)
|
||||
@@ -132,31 +144,75 @@ class ObservabilityService:
|
||||
],
|
||||
}
|
||||
|
||||
def _database_check(self) -> dict[str, Any]:
|
||||
def _safe_call(self, operation: Callable[[], Any]) -> Any:
|
||||
try:
|
||||
self.db.execute(text("select 1")).scalar()
|
||||
except Exception as exc:
|
||||
return operation()
|
||||
except Exception:
|
||||
self.db.rollback()
|
||||
return {
|
||||
ObservabilityKey.STATUS: ObservabilityStatus.ERROR,
|
||||
ObservabilityMetricKey.ERROR: str(exc),
|
||||
ObservabilityMetricKey.ERROR: "unavailable",
|
||||
}
|
||||
|
||||
def _database_check(self) -> dict[str, Any]:
|
||||
self.db.execute(text("select 1")).scalar()
|
||||
return {ObservabilityKey.STATUS: ObservabilityStatus.OK}
|
||||
|
||||
def _redis_check(self) -> dict[str, Any]:
|
||||
settings = get_settings()
|
||||
if not settings.task_queue_enabled:
|
||||
return {ObservabilityKey.STATUS: ObservabilityStatus.SKIPPED}
|
||||
try:
|
||||
from redis import Redis
|
||||
from redis import Redis
|
||||
|
||||
Redis.from_url(settings.redis_url, socket_connect_timeout=1).ping()
|
||||
except Exception as exc:
|
||||
return {
|
||||
ObservabilityKey.STATUS: ObservabilityStatus.DEGRADED,
|
||||
ObservabilityMetricKey.ERROR: str(exc),
|
||||
}
|
||||
client = Redis.from_url(
|
||||
settings.redis_url,
|
||||
socket_connect_timeout=1,
|
||||
socket_timeout=1,
|
||||
)
|
||||
try:
|
||||
client.ping()
|
||||
finally:
|
||||
client.close()
|
||||
return {ObservabilityKey.STATUS: ObservabilityStatus.OK}
|
||||
|
||||
def _upsert_heartbeat(
|
||||
self,
|
||||
component: str,
|
||||
instance_id: str,
|
||||
status_value: str,
|
||||
now: Any,
|
||||
) -> SystemHeartbeat:
|
||||
dialect_name = self.db.get_bind().dialect.name
|
||||
insert_factory = {
|
||||
"postgresql": postgresql_insert,
|
||||
"sqlite": sqlite_insert,
|
||||
}.get(dialect_name)
|
||||
if insert_factory is None:
|
||||
raise RuntimeError(f"Unsupported heartbeat database dialect: {dialect_name}")
|
||||
statement = insert_factory(SystemHeartbeat).values(
|
||||
component=component,
|
||||
instance_id=instance_id,
|
||||
status=status_value,
|
||||
last_seen_at=now,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
statement = statement.on_conflict_do_update(
|
||||
index_elements=["component", "instance_id"],
|
||||
set_={
|
||||
"status": status_value,
|
||||
"last_seen_at": now,
|
||||
"updated_at": now,
|
||||
},
|
||||
)
|
||||
self.db.execute(statement)
|
||||
return self.db.execute(
|
||||
select(SystemHeartbeat).where(
|
||||
SystemHeartbeat.component == component,
|
||||
SystemHeartbeat.instance_id == instance_id,
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
def _events_check(self) -> dict[str, Any]:
|
||||
counts = EventService(self.db).count_by_status()
|
||||
failed = counts.get(EventStatus.FAILED, 0)
|
||||
@@ -196,6 +252,134 @@ class ObservabilityService:
|
||||
],
|
||||
}
|
||||
|
||||
def _feishu_subscriptions_check(self) -> dict[str, Any]:
|
||||
active = int(
|
||||
self.db.scalar(
|
||||
select(func.count())
|
||||
.select_from(PushSubscription)
|
||||
.where(PushSubscription.status == PushSubscriptionStatus.ACTIVE)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
processable_delivery_filter = or_(
|
||||
and_(
|
||||
PushDelivery.status.in_(
|
||||
[
|
||||
PushDeliveryStatus.PENDING,
|
||||
PushDeliveryStatus.RETRY,
|
||||
]
|
||||
),
|
||||
PushDelivery.next_attempt_at.is_not(None),
|
||||
),
|
||||
and_(
|
||||
PushDelivery.status == PushDeliveryStatus.PROCESSING,
|
||||
PushDelivery.locked_until.is_not(None),
|
||||
),
|
||||
)
|
||||
processable_deliveries = int(
|
||||
self.db.scalar(
|
||||
select(func.count())
|
||||
.select_from(PushDelivery)
|
||||
.where(processable_delivery_filter)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
if not active and not processable_deliveries:
|
||||
return {
|
||||
ObservabilityKey.STATUS: ObservabilityStatus.SKIPPED,
|
||||
"active": 0,
|
||||
"processable_deliveries": 0,
|
||||
}
|
||||
settings = get_settings()
|
||||
app_id = str(settings.feishu_app_id or "").strip()
|
||||
credentials_configured = bool(app_id and settings.feishu_app_secret)
|
||||
active_tenant_count = int(
|
||||
self.db.scalar(
|
||||
select(func.count(func.distinct(FeishuUser.tenant_key)))
|
||||
.select_from(PushSubscription)
|
||||
.join(FeishuUser, FeishuUser.id == PushSubscription.owner_id)
|
||||
.outerjoin(
|
||||
PushDelivery,
|
||||
PushDelivery.subscription_id == PushSubscription.id,
|
||||
)
|
||||
.where(
|
||||
or_(
|
||||
PushSubscription.status == PushSubscriptionStatus.ACTIVE,
|
||||
processable_delivery_filter,
|
||||
)
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
ticket_configured = False
|
||||
default_tenant_configured = bool(
|
||||
str(settings.feishu_default_tenant_key or "").strip()
|
||||
)
|
||||
reasons: list[str] = []
|
||||
if not credentials_configured:
|
||||
reasons.append("credentials_missing")
|
||||
if settings.feishu_app_type == FeishuAppType.STORE:
|
||||
database_ticket = (
|
||||
FeishuAppTicketService(self.db).get_ticket(app_id)
|
||||
if app_id
|
||||
else None
|
||||
)
|
||||
ticket_configured = bool(
|
||||
str(database_ticket or settings.feishu_app_ticket or "").strip()
|
||||
)
|
||||
if not ticket_configured:
|
||||
reasons.append("app_ticket_missing")
|
||||
if not default_tenant_configured:
|
||||
reasons.append("default_tenant_missing")
|
||||
elif active_tenant_count > 1:
|
||||
reasons.append("self_app_multiple_tenants")
|
||||
return {
|
||||
ObservabilityKey.STATUS: (
|
||||
ObservabilityStatus.OK
|
||||
if not reasons
|
||||
else ObservabilityStatus.DEGRADED
|
||||
),
|
||||
"active": active,
|
||||
"processable_deliveries": processable_deliveries,
|
||||
"app_type": settings.feishu_app_type,
|
||||
"credentials_configured": credentials_configured,
|
||||
"ticket_configured": ticket_configured,
|
||||
"default_tenant_configured": default_tenant_configured,
|
||||
"active_tenant_count": active_tenant_count,
|
||||
"reasons": reasons,
|
||||
}
|
||||
|
||||
def _feishu_user_metrics(self) -> dict[str, int]:
|
||||
active = int(
|
||||
self.db.scalar(
|
||||
select(func.count())
|
||||
.select_from(FeishuUser)
|
||||
.where(FeishuUser.status == FeishuUserStatus.ACTIVE)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
return {"active": active}
|
||||
|
||||
def _subscription_metrics(self) -> dict[str, int]:
|
||||
active = int(
|
||||
self.db.scalar(
|
||||
select(func.count())
|
||||
.select_from(PushSubscription)
|
||||
.where(PushSubscription.status == PushSubscriptionStatus.ACTIVE)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
delivery_rows = self.db.execute(
|
||||
select(PushDelivery.status, func.count()).group_by(PushDelivery.status)
|
||||
).all()
|
||||
deliveries = {str(status_value): int(count) for status_value, count in delivery_rows}
|
||||
return {
|
||||
"active": active,
|
||||
"pending": deliveries.get(PushDeliveryStatus.PENDING, 0)
|
||||
+ deliveries.get(PushDeliveryStatus.RETRY, 0),
|
||||
"failed": deliveries.get(PushDeliveryStatus.FAILED, 0),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _serialize_heartbeat(record: SystemHeartbeat) -> dict[str, Any]:
|
||||
return {
|
||||
@@ -209,3 +393,12 @@ class ObservabilityService:
|
||||
def _heartbeat_stale_threshold() -> Any:
|
||||
settings = get_settings()
|
||||
return utc_now() - timedelta(seconds=settings.heartbeat_interval_seconds * 3)
|
||||
|
||||
@staticmethod
|
||||
def _heartbeat_retention_threshold() -> Any:
|
||||
settings = get_settings()
|
||||
retention_seconds = max(
|
||||
settings.heartbeat_retention_seconds,
|
||||
settings.heartbeat_interval_seconds * 3,
|
||||
)
|
||||
return utc_now() - timedelta(seconds=retention_seconds)
|
||||
|
||||
23
app/modules/personalization/__init__.py
Normal file
23
app/modules/personalization/__init__.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from app.modules.personalization.models import (
|
||||
AIConversation,
|
||||
AIConversationMessage,
|
||||
PersonalDataErasureRequest,
|
||||
UserPreference,
|
||||
)
|
||||
from app.modules.personalization.services import (
|
||||
ConversationService,
|
||||
PersonalDataErasureService,
|
||||
PersonalizationContextService,
|
||||
PreferenceService,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AIConversation",
|
||||
"AIConversationMessage",
|
||||
"ConversationService",
|
||||
"PersonalDataErasureRequest",
|
||||
"PersonalDataErasureService",
|
||||
"PersonalizationContextService",
|
||||
"PreferenceService",
|
||||
"UserPreference",
|
||||
]
|
||||
101
app/modules/personalization/constants.py
Normal file
101
app/modules/personalization/constants.py
Normal file
@@ -0,0 +1,101 @@
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class PreferenceCategory(StrEnum):
|
||||
LANGUAGE = "language"
|
||||
TONE = "tone"
|
||||
DETAIL = "detail"
|
||||
TOPIC = "topic"
|
||||
INTEREST = "interest"
|
||||
|
||||
|
||||
class PreferenceSource(StrEnum):
|
||||
EXPLICIT = "explicit"
|
||||
AUTO = "auto"
|
||||
|
||||
|
||||
class ConversationRole(StrEnum):
|
||||
USER = "user"
|
||||
ASSISTANT = "assistant"
|
||||
|
||||
|
||||
class ConversationChatType(StrEnum):
|
||||
PRIVATE = "private"
|
||||
GROUP = "group"
|
||||
|
||||
|
||||
class PersonalizationContextKey(StrEnum):
|
||||
SYSTEM_CONSTRAINTS = "system_constraints"
|
||||
COMPANY_RULES = "company_rules"
|
||||
PERSONAL_RULES = "personal_rules"
|
||||
CURRENT_REQUEST = "current_request"
|
||||
PREFERENCES = "preferences"
|
||||
INTERESTS = "interests"
|
||||
PERSONAL_MEMORY = "personal_memory"
|
||||
CONVERSATION_HISTORY = "conversation_history"
|
||||
|
||||
|
||||
PREFERENCE_CODE_PREFIX = "PREF"
|
||||
CONVERSATION_CODE_PREFIX = "CONV"
|
||||
PREFERENCE_MAX_VALUE_LENGTH = 1000
|
||||
CONVERSATION_MAX_CONTENT_LENGTH = 20_000
|
||||
CONVERSATION_RETENTION_DAYS = 30
|
||||
CONVERSATION_MAX_TURNS = 20
|
||||
CONVERSATION_MAX_MESSAGES = CONVERSATION_MAX_TURNS * 2
|
||||
ERASURE_CONFIRMATION_TTL_MINUTES = 10
|
||||
|
||||
UNAVAILABLE_AI_PROVIDERS = frozenset({"", "noop"})
|
||||
PREFERENCE_SIGNAL_TERMS = (
|
||||
"以后",
|
||||
"记住",
|
||||
"偏好",
|
||||
"喜欢",
|
||||
"希望",
|
||||
"请用",
|
||||
"请保持",
|
||||
"关注",
|
||||
"感兴趣",
|
||||
"prefer",
|
||||
"preference",
|
||||
"i like",
|
||||
"interested in",
|
||||
)
|
||||
|
||||
# These terms identify categories that must never become an inferred personal profile.
|
||||
# General topics such as public market news remain allowed; the financial terms below are
|
||||
# intentionally limited to private account, compensation, and confidential-company facts.
|
||||
SENSITIVE_PREFERENCE_TERMS = (
|
||||
"api key",
|
||||
"api_key",
|
||||
"access token",
|
||||
"access_token",
|
||||
"password",
|
||||
"secret",
|
||||
"token",
|
||||
"密码",
|
||||
"密钥",
|
||||
"令牌",
|
||||
"健康",
|
||||
"病史",
|
||||
"疾病",
|
||||
"诊断",
|
||||
"医疗记录",
|
||||
"宗教",
|
||||
"信仰",
|
||||
"政治立场",
|
||||
"党派",
|
||||
"选举倾向",
|
||||
"性取向",
|
||||
"同性恋",
|
||||
"异性恋",
|
||||
"绩效",
|
||||
"考核结果",
|
||||
"银行账号",
|
||||
"银行卡",
|
||||
"工资",
|
||||
"薪资",
|
||||
"个人收入",
|
||||
"财务秘密",
|
||||
"未公开财务",
|
||||
"保密预算",
|
||||
)
|
||||
131
app/modules/personalization/models.py
Normal file
131
app/modules/personalization/models.py
Normal file
@@ -0,0 +1,131 @@
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from app.core.utils.time import utc_now
|
||||
from app.modules.feishu_users.models import FeishuUser
|
||||
from app.modules.personalization.constants import (
|
||||
CONVERSATION_CODE_PREFIX,
|
||||
PREFERENCE_CODE_PREFIX,
|
||||
PreferenceSource,
|
||||
)
|
||||
|
||||
|
||||
def _public_code(prefix: str) -> str:
|
||||
return f"{prefix}-{uuid4().hex}"
|
||||
|
||||
|
||||
class UserPreference(Base):
|
||||
__tablename__ = "user_preferences"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"owner_id",
|
||||
"category",
|
||||
"normalized_value",
|
||||
name="uq_user_preference_owner_category_value",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
code: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
default=lambda: _public_code(PREFERENCE_CODE_PREFIX),
|
||||
unique=True,
|
||||
index=True,
|
||||
)
|
||||
owner_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("feishu_users.id", ondelete="CASCADE"),
|
||||
index=True,
|
||||
)
|
||||
owner: Mapped[FeishuUser] = relationship()
|
||||
category: Mapped[str] = mapped_column(String(32), index=True)
|
||||
value: Mapped[str] = mapped_column(Text)
|
||||
normalized_value: Mapped[str] = mapped_column(String(1000))
|
||||
source: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
default=PreferenceSource.EXPLICIT,
|
||||
index=True,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
default=utc_now,
|
||||
onupdate=utc_now,
|
||||
)
|
||||
|
||||
|
||||
class AIConversation(Base):
|
||||
__tablename__ = "ai_conversations"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"owner_id",
|
||||
"chat_type",
|
||||
"chat_key",
|
||||
name="uq_ai_conversation_owner_chat",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
code: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
default=lambda: _public_code(CONVERSATION_CODE_PREFIX),
|
||||
unique=True,
|
||||
index=True,
|
||||
)
|
||||
owner_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("feishu_users.id", ondelete="CASCADE"),
|
||||
index=True,
|
||||
)
|
||||
owner: Mapped[FeishuUser] = relationship()
|
||||
chat_type: Mapped[str] = mapped_column(String(32), index=True)
|
||||
chat_key: Mapped[str] = mapped_column(String(256), index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
default=utc_now,
|
||||
onupdate=utc_now,
|
||||
index=True,
|
||||
)
|
||||
messages: Mapped[list["AIConversationMessage"]] = relationship(
|
||||
back_populates="conversation",
|
||||
cascade="all, delete-orphan",
|
||||
passive_deletes=True,
|
||||
order_by="AIConversationMessage.id",
|
||||
)
|
||||
|
||||
|
||||
class AIConversationMessage(Base):
|
||||
__tablename__ = "ai_conversation_messages"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
conversation_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("ai_conversations.id", ondelete="CASCADE"),
|
||||
index=True,
|
||||
)
|
||||
role: Mapped[str] = mapped_column(String(32), index=True)
|
||||
content: Mapped[str] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)
|
||||
conversation: Mapped[AIConversation] = relationship(back_populates="messages")
|
||||
|
||||
|
||||
class PersonalDataErasureRequest(Base):
|
||||
__tablename__ = "personal_data_erasure_requests"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
owner_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("feishu_users.id", ondelete="CASCADE"),
|
||||
unique=True,
|
||||
index=True,
|
||||
)
|
||||
owner: Mapped[FeishuUser] = relationship()
|
||||
token_hash: Mapped[str] = mapped_column(String(64))
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
default=utc_now,
|
||||
onupdate=utc_now,
|
||||
)
|
||||
65
app/modules/personalization/schemas.py
Normal file
65
app/modules/personalization/schemas.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.modules.personalization.constants import PreferenceCategory, PreferenceSource
|
||||
|
||||
|
||||
class PreferenceCreate(BaseModel):
|
||||
category: PreferenceCategory
|
||||
value: str = Field(..., min_length=1, max_length=1000)
|
||||
source: PreferenceSource = PreferenceSource.EXPLICIT
|
||||
|
||||
|
||||
class PreferenceUpdate(BaseModel):
|
||||
category: PreferenceCategory | None = None
|
||||
value: str | None = Field(default=None, min_length=1, max_length=1000)
|
||||
|
||||
|
||||
class PreferenceRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
code: str
|
||||
category: str
|
||||
value: str
|
||||
source: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ExtractedPreference(BaseModel):
|
||||
category: PreferenceCategory
|
||||
value: str = Field(..., min_length=1, max_length=1000)
|
||||
|
||||
|
||||
class PreferenceExtractionPayload(BaseModel):
|
||||
preferences: list[ExtractedPreference] = Field(default_factory=list, max_length=20)
|
||||
|
||||
|
||||
class ConversationMessageRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
role: str
|
||||
content: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ConversationRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
code: str
|
||||
chat_type: str
|
||||
chat_key: str
|
||||
messages: list[ConversationMessageRead] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ErasureConfirmation(BaseModel):
|
||||
confirmation_code: str
|
||||
expires_at: datetime
|
||||
|
||||
|
||||
class ErasureResult(BaseModel):
|
||||
anonymous_id: str
|
||||
deleted: dict[str, int] = Field(default_factory=dict)
|
||||
extra: dict[str, Any] = Field(default_factory=dict)
|
||||
15
app/modules/personalization/services/__init__.py
Normal file
15
app/modules/personalization/services/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from app.modules.personalization.services.context import (
|
||||
PersonalizationContext,
|
||||
PersonalizationContextService,
|
||||
)
|
||||
from app.modules.personalization.services.conversations import ConversationService
|
||||
from app.modules.personalization.services.erasure import PersonalDataErasureService
|
||||
from app.modules.personalization.services.preferences import PreferenceService
|
||||
|
||||
__all__ = [
|
||||
"ConversationService",
|
||||
"PersonalDataErasureService",
|
||||
"PersonalizationContext",
|
||||
"PersonalizationContextService",
|
||||
"PreferenceService",
|
||||
]
|
||||
191
app/modules/personalization/services/context.py
Normal file
191
app/modules/personalization/services/context.py
Normal file
@@ -0,0 +1,191 @@
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.constants import ActorValue
|
||||
from app.modules.ai_memory.constants import AIMemoryScope
|
||||
from app.modules.ai_memory.service import AIMemoryService
|
||||
from app.modules.business.models import MarketWatchlist
|
||||
from app.modules.personalization.constants import (
|
||||
PersonalizationContextKey,
|
||||
PreferenceCategory,
|
||||
)
|
||||
from app.modules.personalization.services.conversations import ConversationService
|
||||
from app.modules.personalization.services.preferences import PreferenceService
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PersonalizationContext:
|
||||
"""Ordered, provider-neutral context sections for one AI request."""
|
||||
|
||||
system_constraints: str
|
||||
company_rules: list[dict[str, Any]]
|
||||
personal_rules: list[dict[str, Any]]
|
||||
current_request: str
|
||||
preferences: list[dict[str, Any]]
|
||||
interests: list[dict[str, Any]]
|
||||
personal_memory: list[dict[str, Any]]
|
||||
conversation_history: list[dict[str, Any]]
|
||||
provider_session_id: str | None = field(default=None)
|
||||
|
||||
def as_ordered_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
PersonalizationContextKey.SYSTEM_CONSTRAINTS: self.system_constraints,
|
||||
PersonalizationContextKey.COMPANY_RULES: self.company_rules,
|
||||
PersonalizationContextKey.PERSONAL_RULES: self.personal_rules,
|
||||
PersonalizationContextKey.CURRENT_REQUEST: self.current_request,
|
||||
PersonalizationContextKey.PREFERENCES: self.preferences,
|
||||
PersonalizationContextKey.INTERESTS: self.interests,
|
||||
PersonalizationContextKey.PERSONAL_MEMORY: self.personal_memory,
|
||||
PersonalizationContextKey.CONVERSATION_HISTORY: self.conversation_history,
|
||||
}
|
||||
|
||||
|
||||
class PersonalizationContextService:
|
||||
"""Load only the explicitly requested company and owner-scoped context layers."""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.memory = AIMemoryService(db)
|
||||
self.preferences = PreferenceService(db)
|
||||
self.conversations = ConversationService(db)
|
||||
|
||||
def build(
|
||||
self,
|
||||
*,
|
||||
owner_id: int | None,
|
||||
request: str,
|
||||
system_constraints: str,
|
||||
chat_type: str | None = None,
|
||||
chat_key: str | None = None,
|
||||
scope: str = AIMemoryScope.GLOBAL,
|
||||
subject: str | None = None,
|
||||
actor: str = ActorValue.SYSTEM,
|
||||
include_company_rules: bool = True,
|
||||
include_personal_context: bool = True,
|
||||
include_history: bool = True,
|
||||
) -> PersonalizationContext:
|
||||
company_rules = (
|
||||
self.memory.active_rules(
|
||||
scope=scope,
|
||||
subject=subject,
|
||||
owner_id=None,
|
||||
)
|
||||
if include_company_rules
|
||||
else []
|
||||
)
|
||||
personal_rules: list[dict[str, Any]] = []
|
||||
preference_items: list[dict[str, Any]] = []
|
||||
interests: list[dict[str, Any]] = []
|
||||
personal_memory: list[dict[str, Any]] = []
|
||||
history: list[dict[str, Any]] = []
|
||||
provider_session_id: str | None = None
|
||||
|
||||
if owner_id is not None and include_personal_context:
|
||||
personal_rules = self.memory.active_rules(
|
||||
scope=scope,
|
||||
subject=subject,
|
||||
owner_id=owner_id,
|
||||
)
|
||||
all_preferences = self.preferences.list_preferences(owner_id)
|
||||
interest_categories = {
|
||||
PreferenceCategory.TOPIC,
|
||||
PreferenceCategory.INTEREST,
|
||||
}
|
||||
for preference in all_preferences:
|
||||
if preference["category"] in interest_categories:
|
||||
interests.append(preference)
|
||||
else:
|
||||
preference_items.append(preference)
|
||||
interests.extend(self._watchlist_interests(owner_id))
|
||||
personal_memory = self.memory.recall(
|
||||
query=request,
|
||||
scope=scope,
|
||||
subject=subject,
|
||||
actor=actor,
|
||||
owner_id=owner_id,
|
||||
)
|
||||
if include_history and chat_type and chat_key:
|
||||
history = self.conversations.history(owner_id, chat_type, chat_key)
|
||||
provider_session_id = self.conversations.provider_session_id(
|
||||
owner_id,
|
||||
chat_type,
|
||||
chat_key,
|
||||
)
|
||||
|
||||
return PersonalizationContext(
|
||||
system_constraints=system_constraints,
|
||||
company_rules=company_rules,
|
||||
personal_rules=personal_rules,
|
||||
current_request=request,
|
||||
preferences=preference_items,
|
||||
interests=interests,
|
||||
personal_memory=personal_memory,
|
||||
conversation_history=history,
|
||||
provider_session_id=provider_session_id,
|
||||
)
|
||||
|
||||
def build_private_scheduled(
|
||||
self,
|
||||
*,
|
||||
owner_id: int,
|
||||
request: str,
|
||||
system_constraints: str,
|
||||
scope: str = AIMemoryScope.GLOBAL,
|
||||
subject: str | None = None,
|
||||
actor: str = ActorValue.SYSTEM,
|
||||
) -> PersonalizationContext:
|
||||
"""Build private scheduled context without company data or conversation history."""
|
||||
|
||||
return self.build(
|
||||
owner_id=owner_id,
|
||||
request=request,
|
||||
system_constraints=system_constraints,
|
||||
scope=scope,
|
||||
subject=subject,
|
||||
actor=actor,
|
||||
include_company_rules=False,
|
||||
include_personal_context=True,
|
||||
include_history=False,
|
||||
)
|
||||
|
||||
def build_group_scheduled(
|
||||
self,
|
||||
*,
|
||||
request: str,
|
||||
system_constraints: str,
|
||||
scope: str = AIMemoryScope.GLOBAL,
|
||||
subject: str | None = None,
|
||||
) -> PersonalizationContext:
|
||||
"""Build group scheduled context without any creator profile."""
|
||||
|
||||
return self.build(
|
||||
owner_id=None,
|
||||
request=request,
|
||||
system_constraints=system_constraints,
|
||||
scope=scope,
|
||||
subject=subject,
|
||||
include_company_rules=True,
|
||||
include_personal_context=False,
|
||||
include_history=False,
|
||||
)
|
||||
|
||||
def _watchlist_interests(self, owner_id: int) -> list[dict[str, Any]]:
|
||||
symbols = self.db.execute(
|
||||
select(MarketWatchlist.symbol)
|
||||
.where(
|
||||
MarketWatchlist.owner_id == owner_id,
|
||||
MarketWatchlist.enabled.is_(True),
|
||||
)
|
||||
.order_by(MarketWatchlist.symbol.asc())
|
||||
).scalars()
|
||||
return [
|
||||
{
|
||||
"category": "watchlist",
|
||||
"value": symbol,
|
||||
"source": "market_watchlist",
|
||||
}
|
||||
for symbol in symbols
|
||||
]
|
||||
323
app/modules/personalization/services/conversations.py
Normal file
323
app/modules/personalization/services/conversations.py
Normal file
@@ -0,0 +1,323 @@
|
||||
from datetime import timedelta
|
||||
from hashlib import sha256
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import delete, exists, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils.time import utc_now
|
||||
from app.modules.personalization.constants import (
|
||||
CONVERSATION_MAX_CONTENT_LENGTH,
|
||||
CONVERSATION_MAX_MESSAGES,
|
||||
CONVERSATION_RETENTION_DAYS,
|
||||
UNAVAILABLE_AI_PROVIDERS,
|
||||
ConversationChatType,
|
||||
ConversationRole,
|
||||
)
|
||||
from app.modules.personalization.models import AIConversation, AIConversationMessage
|
||||
|
||||
|
||||
class ConversationService:
|
||||
"""Persist isolated Feishu conversations with bounded history."""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def history(
|
||||
self,
|
||||
owner_id: int,
|
||||
chat_type: str | ConversationChatType,
|
||||
chat_key: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
owner_id, chat_type_value, chat_key_value = _conversation_identity(
|
||||
owner_id,
|
||||
chat_type,
|
||||
chat_key,
|
||||
)
|
||||
self.cleanup_expired(owner_id=owner_id)
|
||||
conversation = self._find(owner_id, chat_type_value, chat_key_value)
|
||||
if conversation is None:
|
||||
self.db.commit()
|
||||
return []
|
||||
messages = list(
|
||||
self.db.execute(
|
||||
select(AIConversationMessage)
|
||||
.where(AIConversationMessage.conversation_id == conversation.id)
|
||||
.order_by(
|
||||
AIConversationMessage.created_at.desc(),
|
||||
AIConversationMessage.id.desc(),
|
||||
)
|
||||
.limit(CONVERSATION_MAX_MESSAGES)
|
||||
).scalars()
|
||||
)
|
||||
self.db.commit()
|
||||
messages.reverse()
|
||||
return [_serialize_message(message) for message in messages]
|
||||
|
||||
def record_turn(
|
||||
self,
|
||||
owner_id: int,
|
||||
chat_type: str | ConversationChatType,
|
||||
chat_key: str,
|
||||
*,
|
||||
user_content: str,
|
||||
assistant_content: str,
|
||||
provider_name: str,
|
||||
ai_available: bool = True,
|
||||
) -> bool:
|
||||
"""Record one complete turn only after a real AI answer succeeds."""
|
||||
|
||||
if not ai_available or provider_name.strip().lower() in UNAVAILABLE_AI_PROVIDERS:
|
||||
return False
|
||||
owner_id, chat_type_value, chat_key_value = _conversation_identity(
|
||||
owner_id,
|
||||
chat_type,
|
||||
chat_key,
|
||||
)
|
||||
user_text = _message_content(user_content)
|
||||
assistant_text = _message_content(assistant_content)
|
||||
self.cleanup_expired(owner_id=owner_id)
|
||||
conversation = self._get_or_create(
|
||||
owner_id,
|
||||
chat_type_value,
|
||||
chat_key_value,
|
||||
)
|
||||
now = utc_now()
|
||||
self.db.add_all(
|
||||
[
|
||||
AIConversationMessage(
|
||||
conversation_id=conversation.id,
|
||||
role=ConversationRole.USER,
|
||||
content=user_text,
|
||||
created_at=now,
|
||||
),
|
||||
AIConversationMessage(
|
||||
conversation_id=conversation.id,
|
||||
role=ConversationRole.ASSISTANT,
|
||||
content=assistant_text,
|
||||
created_at=now,
|
||||
),
|
||||
]
|
||||
)
|
||||
conversation.updated_at = now
|
||||
self.db.flush()
|
||||
self._trim(conversation.id)
|
||||
self.db.commit()
|
||||
return True
|
||||
|
||||
def reset(
|
||||
self,
|
||||
owner_id: int,
|
||||
chat_type: str | ConversationChatType,
|
||||
chat_key: str,
|
||||
) -> bool:
|
||||
owner_id, chat_type_value, chat_key_value = _conversation_identity(
|
||||
owner_id,
|
||||
chat_type,
|
||||
chat_key,
|
||||
)
|
||||
conversation = self._find(owner_id, chat_type_value, chat_key_value)
|
||||
if conversation is None:
|
||||
return False
|
||||
self.db.execute(
|
||||
delete(AIConversationMessage).where(
|
||||
AIConversationMessage.conversation_id == conversation.id
|
||||
)
|
||||
)
|
||||
self.db.delete(conversation)
|
||||
self.db.commit()
|
||||
return True
|
||||
|
||||
def cleanup_expired(self, owner_id: int | None = None) -> int:
|
||||
"""Stage retention cleanup for one owner or all owners.
|
||||
|
||||
The surrounding operation owns the transaction. Interactive history and
|
||||
write paths use this method before completing their own commit.
|
||||
"""
|
||||
|
||||
return self._cleanup_expired(owner_id)["conversation_messages"]
|
||||
|
||||
def cleanup_expired_globally(self) -> dict[str, int]:
|
||||
"""Delete expired messages for every owner and commit the maintenance run."""
|
||||
|
||||
deleted = self._cleanup_expired(owner_id=None)
|
||||
self.db.commit()
|
||||
return deleted
|
||||
|
||||
def _cleanup_expired(self, owner_id: int | None) -> dict[str, int]:
|
||||
cutoff = utc_now() - timedelta(days=CONVERSATION_RETENTION_DAYS)
|
||||
conversation_ids = select(AIConversation.id)
|
||||
if owner_id is not None:
|
||||
conversation_ids = conversation_ids.where(AIConversation.owner_id == owner_id)
|
||||
message_result = self.db.execute(
|
||||
delete(AIConversationMessage).where(
|
||||
AIConversationMessage.conversation_id.in_(conversation_ids),
|
||||
AIConversationMessage.created_at < cutoff,
|
||||
)
|
||||
)
|
||||
empty_conversations = delete(AIConversation).where(
|
||||
~exists(
|
||||
select(AIConversationMessage.id).where(
|
||||
AIConversationMessage.conversation_id == AIConversation.id
|
||||
)
|
||||
)
|
||||
)
|
||||
if owner_id is not None:
|
||||
empty_conversations = empty_conversations.where(
|
||||
AIConversation.owner_id == owner_id
|
||||
)
|
||||
conversation_result = self.db.execute(empty_conversations)
|
||||
return {
|
||||
"conversation_messages": max(0, int(message_result.rowcount or 0)),
|
||||
"conversations": max(0, int(conversation_result.rowcount or 0)),
|
||||
}
|
||||
|
||||
def delete_owner_conversations(self, owner_id: int) -> dict[str, int]:
|
||||
conversation_ids = select(AIConversation.id).where(
|
||||
AIConversation.owner_id == owner_id
|
||||
)
|
||||
message_result = self.db.execute(
|
||||
delete(AIConversationMessage).where(
|
||||
AIConversationMessage.conversation_id.in_(conversation_ids)
|
||||
)
|
||||
)
|
||||
conversation_result = self.db.execute(
|
||||
delete(AIConversation).where(AIConversation.owner_id == owner_id)
|
||||
)
|
||||
return {
|
||||
"conversation_messages": max(0, int(message_result.rowcount or 0)),
|
||||
"conversations": max(0, int(conversation_result.rowcount or 0)),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def provider_session_id(
|
||||
owner_id: int,
|
||||
chat_type: str | ConversationChatType,
|
||||
chat_key: str,
|
||||
) -> str:
|
||||
owner_id, chat_type_value, chat_key_value = _conversation_identity(
|
||||
owner_id,
|
||||
chat_type,
|
||||
chat_key,
|
||||
)
|
||||
digest = sha256(
|
||||
f"{owner_id}\0{chat_type_value}\0{chat_key_value}".encode("utf-8")
|
||||
).hexdigest()
|
||||
return f"feishu-{digest}"
|
||||
|
||||
def _find(
|
||||
self,
|
||||
owner_id: int,
|
||||
chat_type: str,
|
||||
chat_key: str,
|
||||
) -> AIConversation | None:
|
||||
return self.db.execute(
|
||||
select(AIConversation).where(
|
||||
AIConversation.owner_id == owner_id,
|
||||
AIConversation.chat_type == chat_type,
|
||||
AIConversation.chat_key == chat_key,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
def _get_or_create(
|
||||
self,
|
||||
owner_id: int,
|
||||
chat_type: str,
|
||||
chat_key: str,
|
||||
) -> AIConversation:
|
||||
existing = self._find(owner_id, chat_type, chat_key)
|
||||
if existing is not None:
|
||||
return existing
|
||||
conversation = AIConversation(
|
||||
owner_id=owner_id,
|
||||
chat_type=chat_type,
|
||||
chat_key=chat_key,
|
||||
)
|
||||
try:
|
||||
with self.db.begin_nested():
|
||||
self.db.add(conversation)
|
||||
self.db.flush()
|
||||
except IntegrityError:
|
||||
conversation = self._find(owner_id, chat_type, chat_key)
|
||||
if conversation is None:
|
||||
raise
|
||||
return conversation
|
||||
|
||||
def _trim(self, conversation_id: int) -> int:
|
||||
stale_ids = list(
|
||||
self.db.execute(
|
||||
select(AIConversationMessage.id)
|
||||
.where(AIConversationMessage.conversation_id == conversation_id)
|
||||
.order_by(
|
||||
AIConversationMessage.created_at.desc(),
|
||||
AIConversationMessage.id.desc(),
|
||||
)
|
||||
.offset(CONVERSATION_MAX_MESSAGES)
|
||||
).scalars()
|
||||
)
|
||||
if not stale_ids:
|
||||
return 0
|
||||
result = self.db.execute(
|
||||
delete(AIConversationMessage).where(
|
||||
AIConversationMessage.id.in_(stale_ids)
|
||||
)
|
||||
)
|
||||
return max(0, int(result.rowcount or 0))
|
||||
|
||||
|
||||
def _conversation_identity(
|
||||
owner_id: int,
|
||||
chat_type: str | ConversationChatType,
|
||||
chat_key: str,
|
||||
) -> tuple[int, str, str]:
|
||||
if owner_id <= 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Valid conversation owner is required",
|
||||
)
|
||||
raw_chat_type = str(chat_type).strip().lower()
|
||||
aliases = {
|
||||
"p2p": ConversationChatType.PRIVATE,
|
||||
"private": ConversationChatType.PRIVATE,
|
||||
"group": ConversationChatType.GROUP,
|
||||
"group_chat": ConversationChatType.GROUP,
|
||||
}
|
||||
try:
|
||||
chat_type_value = str(aliases[raw_chat_type])
|
||||
except KeyError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Unsupported conversation chat type",
|
||||
) from exc
|
||||
chat_key_value = str(chat_key).strip()
|
||||
if not chat_key_value or len(chat_key_value) > 256:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Valid conversation chat key is required",
|
||||
)
|
||||
return owner_id, chat_type_value, chat_key_value
|
||||
|
||||
|
||||
def _message_content(value: str) -> str:
|
||||
content = str(value).strip()
|
||||
if not content:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Conversation message is required",
|
||||
)
|
||||
if len(content) > CONVERSATION_MAX_CONTENT_LENGTH:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Conversation message is too long",
|
||||
)
|
||||
return content
|
||||
|
||||
|
||||
def _serialize_message(message: AIConversationMessage) -> dict[str, Any]:
|
||||
return {
|
||||
"role": message.role,
|
||||
"content": message.content,
|
||||
"created_at": message.created_at.isoformat(),
|
||||
}
|
||||
205
app/modules/personalization/services/erasure.py
Normal file
205
app/modules/personalization/services/erasure.py
Normal file
@@ -0,0 +1,205 @@
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from datetime import timedelta
|
||||
from hashlib import sha256
|
||||
from hmac import compare_digest
|
||||
from secrets import token_hex
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils.time import utc_now
|
||||
from app.modules.ai_memory.models import AIMemoryEntry
|
||||
from app.modules.business.models import MarketWatchlist
|
||||
from app.modules.personalization.constants import ERASURE_CONFIRMATION_TTL_MINUTES
|
||||
from app.modules.personalization.models import (
|
||||
AIConversation,
|
||||
AIConversationMessage,
|
||||
PersonalDataErasureRequest,
|
||||
UserPreference,
|
||||
)
|
||||
from app.modules.personalization.schemas import ErasureConfirmation, ErasureResult
|
||||
|
||||
ErasureHook = Callable[
|
||||
[Session, int, str],
|
||||
int | Mapping[str, int] | None,
|
||||
]
|
||||
|
||||
|
||||
class PersonalDataErasureService:
|
||||
"""Issue one-time confirmations and erase owner data in one transaction."""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def request_confirmation(
|
||||
self,
|
||||
owner_id: int,
|
||||
*,
|
||||
ttl_minutes: int = ERASURE_CONFIRMATION_TTL_MINUTES,
|
||||
) -> ErasureConfirmation:
|
||||
_validate_owner(owner_id)
|
||||
if ttl_minutes <= 0 or ttl_minutes > 60:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Invalid erasure confirmation lifetime",
|
||||
)
|
||||
confirmation_code = token_hex(4).upper()
|
||||
now = utc_now()
|
||||
expires_at = now + timedelta(minutes=ttl_minutes)
|
||||
record = self.db.execute(
|
||||
select(PersonalDataErasureRequest).where(
|
||||
PersonalDataErasureRequest.owner_id == owner_id
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if record is None:
|
||||
record = PersonalDataErasureRequest(
|
||||
owner_id=owner_id,
|
||||
token_hash=_token_hash(owner_id, confirmation_code),
|
||||
expires_at=expires_at,
|
||||
)
|
||||
self.db.add(record)
|
||||
else:
|
||||
record.token_hash = _token_hash(owner_id, confirmation_code)
|
||||
record.expires_at = expires_at
|
||||
record.updated_at = now
|
||||
self.db.commit()
|
||||
return ErasureConfirmation(
|
||||
confirmation_code=confirmation_code,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
def confirm_and_erase(
|
||||
self,
|
||||
owner_id: int,
|
||||
confirmation_code: str,
|
||||
*,
|
||||
before_hooks: Sequence[ErasureHook] = (),
|
||||
extra_hooks: Sequence[ErasureHook] = (),
|
||||
) -> ErasureResult:
|
||||
"""Erase core personal tables and run integration hooks before one commit."""
|
||||
|
||||
_validate_owner(owner_id)
|
||||
request = self.db.execute(
|
||||
select(PersonalDataErasureRequest)
|
||||
.where(PersonalDataErasureRequest.owner_id == owner_id)
|
||||
.with_for_update()
|
||||
).scalar_one_or_none()
|
||||
if request is None or request.expires_at <= utc_now():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Erasure confirmation is invalid or expired",
|
||||
)
|
||||
supplied_hash = _token_hash(owner_id, confirmation_code.strip().upper())
|
||||
if not compare_digest(supplied_hash, request.token_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Erasure confirmation is invalid or expired",
|
||||
)
|
||||
|
||||
anonymous_id = f"anonymous-{uuid4().hex}"
|
||||
deleted: dict[str, int] = {}
|
||||
extra: dict[str, Any] = {}
|
||||
try:
|
||||
for index, hook in enumerate(before_hooks):
|
||||
_merge_hook_result(
|
||||
hook(self.db, owner_id, anonymous_id),
|
||||
index=index,
|
||||
prefix="before",
|
||||
deleted=deleted,
|
||||
extra=extra,
|
||||
)
|
||||
conversation_ids = select(AIConversation.id).where(
|
||||
AIConversation.owner_id == owner_id
|
||||
)
|
||||
deleted["conversation_messages"] = _row_count(
|
||||
self.db.execute(
|
||||
delete(AIConversationMessage).where(
|
||||
AIConversationMessage.conversation_id.in_(conversation_ids)
|
||||
)
|
||||
).rowcount
|
||||
)
|
||||
deleted["conversations"] = _row_count(
|
||||
self.db.execute(
|
||||
delete(AIConversation).where(AIConversation.owner_id == owner_id)
|
||||
).rowcount
|
||||
)
|
||||
deleted["preferences"] = _row_count(
|
||||
self.db.execute(
|
||||
delete(UserPreference).where(UserPreference.owner_id == owner_id)
|
||||
).rowcount
|
||||
)
|
||||
deleted["ai_memory"] = _row_count(
|
||||
self.db.execute(
|
||||
delete(AIMemoryEntry).where(AIMemoryEntry.owner_id == owner_id)
|
||||
).rowcount
|
||||
)
|
||||
deleted["watchlist"] = _row_count(
|
||||
self.db.execute(
|
||||
delete(MarketWatchlist).where(MarketWatchlist.owner_id == owner_id)
|
||||
).rowcount
|
||||
)
|
||||
self.db.delete(request)
|
||||
|
||||
for index, hook in enumerate(extra_hooks):
|
||||
_merge_hook_result(
|
||||
hook(self.db, owner_id, anonymous_id),
|
||||
index=index,
|
||||
prefix="extra",
|
||||
deleted=deleted,
|
||||
extra=extra,
|
||||
)
|
||||
self.db.commit()
|
||||
except Exception:
|
||||
self.db.rollback()
|
||||
raise
|
||||
return ErasureResult(
|
||||
anonymous_id=anonymous_id,
|
||||
deleted=deleted,
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
def purge_expired_confirmations(self) -> int:
|
||||
result = self.db.execute(
|
||||
delete(PersonalDataErasureRequest).where(
|
||||
PersonalDataErasureRequest.expires_at <= utc_now()
|
||||
)
|
||||
)
|
||||
count = _row_count(result.rowcount)
|
||||
self.db.commit()
|
||||
return count
|
||||
|
||||
|
||||
def _token_hash(owner_id: int, confirmation_code: str) -> str:
|
||||
return sha256(f"{owner_id}\0{confirmation_code}".encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _validate_owner(owner_id: int) -> None:
|
||||
if owner_id <= 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Valid erasure owner is required",
|
||||
)
|
||||
|
||||
|
||||
def _row_count(value: int | None) -> int:
|
||||
return max(0, int(value or 0))
|
||||
|
||||
|
||||
def _merge_hook_result(
|
||||
hook_result: int | Mapping[str, int] | None,
|
||||
*,
|
||||
index: int,
|
||||
prefix: str,
|
||||
deleted: dict[str, int],
|
||||
extra: dict[str, Any],
|
||||
) -> None:
|
||||
if isinstance(hook_result, Mapping):
|
||||
for key, value in hook_result.items():
|
||||
deleted[str(key)] = int(value)
|
||||
elif isinstance(hook_result, int):
|
||||
deleted[f"{prefix}_{index}"] = hook_result
|
||||
elif hook_result is not None:
|
||||
extra[f"{prefix}_{index}"] = hook_result
|
||||
315
app/modules/personalization/services/preferences.py
Normal file
315
app/modules/personalization/services/preferences.py
Normal file
@@ -0,0 +1,315 @@
|
||||
import json
|
||||
import re
|
||||
import unicodedata
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.personalization.constants import (
|
||||
PREFERENCE_MAX_VALUE_LENGTH,
|
||||
PREFERENCE_SIGNAL_TERMS,
|
||||
SENSITIVE_PREFERENCE_TERMS,
|
||||
UNAVAILABLE_AI_PROVIDERS,
|
||||
PreferenceCategory,
|
||||
PreferenceSource,
|
||||
)
|
||||
from app.modules.personalization.models import UserPreference
|
||||
from app.modules.personalization.schemas import PreferenceExtractionPayload
|
||||
|
||||
|
||||
class PreferenceService:
|
||||
"""Manage explicit and safely extracted preferences inside one owner boundary."""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def list_preferences(
|
||||
self,
|
||||
owner_id: int,
|
||||
category: str | PreferenceCategory | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
_validate_owner(owner_id)
|
||||
stmt = (
|
||||
select(UserPreference)
|
||||
.where(UserPreference.owner_id == owner_id)
|
||||
.order_by(UserPreference.category.asc(), UserPreference.id.asc())
|
||||
)
|
||||
if category is not None:
|
||||
stmt = stmt.where(UserPreference.category == _category_value(category))
|
||||
return [_serialize(item) for item in self.db.execute(stmt).scalars()]
|
||||
|
||||
def upsert(
|
||||
self,
|
||||
owner_id: int,
|
||||
category: str | PreferenceCategory,
|
||||
value: str,
|
||||
source: str | PreferenceSource = PreferenceSource.EXPLICIT,
|
||||
*,
|
||||
commit: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Create one owner-scoped preference or reuse its normalized equivalent."""
|
||||
|
||||
_validate_owner(owner_id)
|
||||
category_value, clean_value, normalized = validate_preference(category, value)
|
||||
source_value = _source_value(source)
|
||||
existing = self.db.execute(
|
||||
select(UserPreference).where(
|
||||
UserPreference.owner_id == owner_id,
|
||||
UserPreference.category == category_value,
|
||||
UserPreference.normalized_value == normalized,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
existing.value = clean_value
|
||||
if source_value == PreferenceSource.EXPLICIT:
|
||||
existing.source = source_value
|
||||
if commit:
|
||||
self.db.commit()
|
||||
self.db.refresh(existing)
|
||||
return _serialize(existing)
|
||||
|
||||
record = UserPreference(
|
||||
owner_id=owner_id,
|
||||
category=category_value,
|
||||
value=clean_value,
|
||||
normalized_value=normalized,
|
||||
source=source_value,
|
||||
)
|
||||
try:
|
||||
with self.db.begin_nested():
|
||||
self.db.add(record)
|
||||
self.db.flush()
|
||||
except IntegrityError:
|
||||
record = self.db.execute(
|
||||
select(UserPreference).where(
|
||||
UserPreference.owner_id == owner_id,
|
||||
UserPreference.category == category_value,
|
||||
UserPreference.normalized_value == normalized,
|
||||
)
|
||||
).scalar_one()
|
||||
if commit:
|
||||
self.db.commit()
|
||||
self.db.refresh(record)
|
||||
return _serialize(record)
|
||||
|
||||
def update(
|
||||
self,
|
||||
owner_id: int,
|
||||
code: str,
|
||||
*,
|
||||
category: str | PreferenceCategory | None = None,
|
||||
value: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
record = self._owned_record(owner_id, code)
|
||||
next_category = category if category is not None else record.category
|
||||
next_value = value if value is not None else record.value
|
||||
category_value, clean_value, normalized = validate_preference(
|
||||
next_category,
|
||||
next_value,
|
||||
)
|
||||
try:
|
||||
with self.db.begin_nested():
|
||||
record.category = category_value
|
||||
record.value = clean_value
|
||||
record.normalized_value = normalized
|
||||
self.db.flush()
|
||||
except IntegrityError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Preference already exists",
|
||||
) from exc
|
||||
self.db.commit()
|
||||
self.db.refresh(record)
|
||||
return _serialize(record)
|
||||
|
||||
def delete(self, owner_id: int, code: str) -> None:
|
||||
record = self._owned_record(owner_id, code)
|
||||
self.db.delete(record)
|
||||
self.db.commit()
|
||||
|
||||
def delete_matching(
|
||||
self,
|
||||
owner_id: int,
|
||||
*,
|
||||
category: str | PreferenceCategory,
|
||||
value: str,
|
||||
) -> bool:
|
||||
category_value, _, normalized = validate_preference(category, value)
|
||||
record = self.db.execute(
|
||||
select(UserPreference).where(
|
||||
UserPreference.owner_id == owner_id,
|
||||
UserPreference.category == category_value,
|
||||
UserPreference.normalized_value == normalized,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if record is None:
|
||||
return False
|
||||
self.db.delete(record)
|
||||
self.db.commit()
|
||||
return True
|
||||
|
||||
def save_auto_extraction(
|
||||
self,
|
||||
owner_id: int,
|
||||
*,
|
||||
provider_name: str,
|
||||
user_text: str,
|
||||
structured_payload: str | dict[str, Any] | list[Any],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Persist only allowlisted, non-sensitive output from a real AI provider."""
|
||||
|
||||
if provider_name.strip().lower() in UNAVAILABLE_AI_PROVIDERS:
|
||||
return []
|
||||
if not contains_preference_signal(user_text):
|
||||
return []
|
||||
candidates = _parse_extraction_payload(structured_payload)
|
||||
saved: list[dict[str, Any]] = []
|
||||
for candidate in candidates:
|
||||
try:
|
||||
saved.append(
|
||||
self.upsert(
|
||||
owner_id=owner_id,
|
||||
category=candidate.category,
|
||||
value=candidate.value,
|
||||
source=PreferenceSource.AUTO,
|
||||
commit=False,
|
||||
)
|
||||
)
|
||||
except HTTPException:
|
||||
# Automatic extraction is intentionally silent. Invalid or sensitive
|
||||
# candidates are discarded without creating a rejected profile row.
|
||||
continue
|
||||
if saved:
|
||||
self.db.commit()
|
||||
return saved
|
||||
|
||||
def delete_owner_preferences(self, owner_id: int) -> int:
|
||||
"""Stage deletion of every preference for an owner."""
|
||||
|
||||
result = self.db.execute(
|
||||
delete(UserPreference).where(UserPreference.owner_id == owner_id)
|
||||
)
|
||||
return max(0, int(result.rowcount or 0))
|
||||
|
||||
def _owned_record(self, owner_id: int, code: str) -> UserPreference:
|
||||
_validate_owner(owner_id)
|
||||
record = self.db.execute(
|
||||
select(UserPreference).where(
|
||||
UserPreference.owner_id == owner_id,
|
||||
UserPreference.code == code,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if record is None:
|
||||
# Deliberately indistinguishable from a nonexistent code.
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Preference not found",
|
||||
)
|
||||
return record
|
||||
|
||||
|
||||
def contains_preference_signal(value: str) -> bool:
|
||||
text = unicodedata.normalize("NFKC", value).casefold()
|
||||
return any(term in text for term in PREFERENCE_SIGNAL_TERMS)
|
||||
|
||||
|
||||
def validate_preference(
|
||||
category: str | PreferenceCategory,
|
||||
value: str,
|
||||
) -> tuple[str, str, str]:
|
||||
category_value = _category_value(category)
|
||||
clean_value = _clean_value(value)
|
||||
if is_sensitive_preference(clean_value):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Sensitive preference content is not allowed",
|
||||
)
|
||||
return category_value, clean_value, normalize_preference_value(clean_value)
|
||||
|
||||
|
||||
def normalize_preference_value(value: str) -> str:
|
||||
normalized = unicodedata.normalize("NFKC", value)
|
||||
normalized = re.sub(r"\s+", " ", normalized).strip()
|
||||
return normalized.casefold()
|
||||
|
||||
|
||||
def is_sensitive_preference(value: str) -> bool:
|
||||
normalized = unicodedata.normalize("NFKC", value).casefold()
|
||||
return any(term.casefold() in normalized for term in SENSITIVE_PREFERENCE_TERMS)
|
||||
|
||||
|
||||
def _parse_extraction_payload(
|
||||
payload: str | dict[str, Any] | list[Any],
|
||||
) -> list[Any]:
|
||||
parsed: Any = payload
|
||||
if isinstance(payload, str):
|
||||
try:
|
||||
parsed = json.loads(payload)
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
if isinstance(parsed, list):
|
||||
parsed = {"preferences": parsed}
|
||||
elif isinstance(parsed, dict) and "preferences" not in parsed:
|
||||
parsed = {"preferences": [parsed]}
|
||||
try:
|
||||
return PreferenceExtractionPayload.model_validate(parsed).preferences
|
||||
except ValidationError:
|
||||
return []
|
||||
|
||||
|
||||
def _category_value(category: str | PreferenceCategory) -> str:
|
||||
try:
|
||||
return str(PreferenceCategory(category))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Unsupported preference category",
|
||||
) from exc
|
||||
|
||||
|
||||
def _source_value(source: str | PreferenceSource) -> str:
|
||||
try:
|
||||
return str(PreferenceSource(source))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Unsupported preference source",
|
||||
) from exc
|
||||
|
||||
|
||||
def _clean_value(value: str) -> str:
|
||||
clean_value = unicodedata.normalize("NFKC", str(value)).strip()
|
||||
if not clean_value:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Preference value is required",
|
||||
)
|
||||
if len(clean_value) > PREFERENCE_MAX_VALUE_LENGTH:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Preference value is too long",
|
||||
)
|
||||
return clean_value
|
||||
|
||||
|
||||
def _validate_owner(owner_id: int) -> None:
|
||||
if owner_id <= 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Valid preference owner is required",
|
||||
)
|
||||
|
||||
|
||||
def _serialize(record: UserPreference) -> dict[str, Any]:
|
||||
return {
|
||||
"code": record.code,
|
||||
"category": record.category,
|
||||
"value": record.value,
|
||||
"source": record.source,
|
||||
"created_at": record.created_at.isoformat(),
|
||||
"updated_at": record.updated_at.isoformat(),
|
||||
}
|
||||
@@ -14,7 +14,7 @@ from app.core.background.task_queue import (
|
||||
enqueue_work_weekly_push,
|
||||
)
|
||||
from app.core.database import get_db
|
||||
from app.core.security import ApiPrincipal, require_api_key, require_operations_enabled
|
||||
from app.core.security import ApiPrincipal, require_api_key
|
||||
from app.modules.reports.constants import ReportPushKey
|
||||
from app.modules.reports.schemas import (
|
||||
LifecycleRunRequest,
|
||||
@@ -134,7 +134,6 @@ def enqueue_lifecycle(
|
||||
payload: LifecycleRunRequest,
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
require_operations_enabled()
|
||||
return enqueue_lifecycle_report(
|
||||
report_type=payload.report_type,
|
||||
receive_id=payload.receive_id,
|
||||
@@ -167,8 +166,6 @@ def generate_work_report(
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
if payload.persist:
|
||||
require_operations_enabled()
|
||||
return ReportService(db).generate_work_report(
|
||||
report_type=payload.report_type,
|
||||
reporter=payload.reporter,
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
from datetime import date
|
||||
from hashlib import sha256
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
|
||||
from app.core.constants import ActorValue
|
||||
from app.modules.audit.constants import AuditAction, AuditSource, AuditTargetType
|
||||
@@ -26,7 +30,7 @@ from app.modules.reports.constants import (
|
||||
ReportTitle,
|
||||
)
|
||||
|
||||
from app.modules.reports.services.common import _json_safe, _money, _next_code, _rate
|
||||
from app.modules.reports.services.common import _json_safe, _money, _rate
|
||||
|
||||
|
||||
class ReportEnterpriseAnalyticsMixin:
|
||||
@@ -39,8 +43,15 @@ class ReportEnterpriseAnalyticsMixin:
|
||||
actor: str = ActorValue.API,
|
||||
) -> dict[str, Any]:
|
||||
"""Build V3 read-only finance, procurement, performance, and operations analytics."""
|
||||
if period_start and period_end and period_start > period_end:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="period_start must be before or equal to period_end",
|
||||
)
|
||||
|
||||
code = _next_code("ANALYTICS")
|
||||
include_global_metrics = not any(
|
||||
value is not None for value in (project_code, owner, period_start, period_end)
|
||||
)
|
||||
lifecycle = self.project_lifecycle_report(
|
||||
project_code=project_code,
|
||||
owner=owner,
|
||||
@@ -61,9 +72,17 @@ class ReportEnterpriseAnalyticsMixin:
|
||||
MetricKey.BUDGET_TOTAL: projects[MetricKey.BUDGET_TOTAL],
|
||||
MetricKey.ACTUAL_TOTAL: projects[MetricKey.ACTUAL_TOTAL],
|
||||
MetricKey.BUDGET_USAGE_RATE: projects[MetricKey.BUDGET_USAGE_RATE],
|
||||
MetricKey.CURRENT_BALANCE_TOTAL: funds[MetricKey.CURRENT_BALANCE_TOTAL],
|
||||
MetricKey.NET_POSITION: funds[MetricKey.NET_POSITION],
|
||||
MetricKey.RISK_ACCOUNTS: funds[MetricKey.RISK_ACCOUNTS],
|
||||
MetricKey.CURRENT_BALANCE_TOTAL: (
|
||||
funds[MetricKey.CURRENT_BALANCE_TOTAL]
|
||||
if include_global_metrics
|
||||
else 0
|
||||
),
|
||||
MetricKey.NET_POSITION: (
|
||||
funds[MetricKey.NET_POSITION] if include_global_metrics else 0
|
||||
),
|
||||
MetricKey.RISK_ACCOUNTS: (
|
||||
funds[MetricKey.RISK_ACCOUNTS] if include_global_metrics else 0
|
||||
),
|
||||
MetricKey.PAYMENT_EXPOSURE: (
|
||||
procurements[MetricKey.ACTUAL_TOTAL] + expenses[MetricKey.AMOUNT_TOTAL]
|
||||
),
|
||||
@@ -77,7 +96,7 @@ class ReportEnterpriseAnalyticsMixin:
|
||||
MetricKey.ACTUAL_TOTAL: procurements[MetricKey.ACTUAL_TOTAL],
|
||||
MetricKey.DELIVERY_RISK: procurements[MetricKey.PENDING_DELIVERY],
|
||||
}
|
||||
performance = self._enterprise_performance_stats()
|
||||
performance = self._enterprise_performance_stats(include_global_metrics)
|
||||
operations = {
|
||||
MetricKey.READINESS_SCORE: health[MetricKey.SCORE],
|
||||
MetricKey.LEVEL: health[MetricKey.LEVEL],
|
||||
@@ -97,9 +116,8 @@ class ReportEnterpriseAnalyticsMixin:
|
||||
operations,
|
||||
recommendations,
|
||||
)
|
||||
report = _json_safe(
|
||||
snapshot = _json_safe(
|
||||
{
|
||||
EnterpriseAnalyticsKey.CODE: code,
|
||||
EnterpriseAnalyticsKey.TITLE: ReportTitle.ENTERPRISE_ANALYTICS,
|
||||
EnterpriseAnalyticsKey.FILTERS: lifecycle[LifecycleResponseKey.FILTERS],
|
||||
EnterpriseAnalyticsKey.FINANCE: finance,
|
||||
@@ -111,6 +129,16 @@ class ReportEnterpriseAnalyticsMixin:
|
||||
EnterpriseAnalyticsKey.CONTENT: "\n".join(lines),
|
||||
}
|
||||
)
|
||||
canonical_snapshot = json.dumps(
|
||||
snapshot,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
default=str,
|
||||
)
|
||||
digest = sha256(canonical_snapshot.encode("utf-8")).hexdigest()[:24].upper()
|
||||
code = f"ANALYTICS-{digest}"
|
||||
report = {EnterpriseAnalyticsKey.CODE: code, **snapshot}
|
||||
AuditService(self.db).record(
|
||||
AuditLogCreate(
|
||||
actor=actor,
|
||||
@@ -136,7 +164,18 @@ class ReportEnterpriseAnalyticsMixin:
|
||||
self.db.commit()
|
||||
return report
|
||||
|
||||
def _enterprise_performance_stats(self) -> dict[str, Any]:
|
||||
def _enterprise_performance_stats(self, include_global: bool) -> dict[str, Any]:
|
||||
if not include_global:
|
||||
return {
|
||||
MetricKey.TOTAL: 0,
|
||||
MetricKey.CONFIRMED: 0,
|
||||
MetricKey.CONFIRMED_RATE: 0.0,
|
||||
MetricKey.AVERAGE_AUTO_SCORE: 0.0,
|
||||
MetricKey.AVERAGE_CONFIRMED_SCORE: 0.0,
|
||||
MetricKey.WEIGHT_TOTAL: 0,
|
||||
MetricKey.BY_STATUS: {},
|
||||
}
|
||||
|
||||
total = self._count(PerformanceMetric)
|
||||
confirmed = self._count(PerformanceMetric, PerformanceMetric.confirmed_score.is_not(None))
|
||||
return {
|
||||
|
||||
@@ -45,7 +45,9 @@ class ReportLifecycleReportMixin:
|
||||
task_conditions,
|
||||
risk_conditions,
|
||||
)
|
||||
include_global_risk = not (project_code or owner)
|
||||
include_global_risk = not any(
|
||||
value is not None for value in (project_code, owner, period_start, period_end)
|
||||
)
|
||||
health = self._lifecycle_health(
|
||||
project_stats,
|
||||
task_stats,
|
||||
|
||||
@@ -26,6 +26,7 @@ class ReportPushRunMixin:
|
||||
receive_id_type: str,
|
||||
actor: str,
|
||||
status: str = ReportPushStatus.PENDING,
|
||||
task_id: str | None = None,
|
||||
idempotency_key: str | None = None,
|
||||
) -> ReportPushRun:
|
||||
if idempotency_key:
|
||||
@@ -43,6 +44,7 @@ class ReportPushRunMixin:
|
||||
receive_id=receive_id,
|
||||
receive_id_type=receive_id_type,
|
||||
status=status,
|
||||
task_id=task_id,
|
||||
actor=actor,
|
||||
queued_at=utc_now(),
|
||||
idempotency_key=idempotency_key,
|
||||
|
||||
@@ -3,7 +3,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.background.task_queue import enqueue_risk_event_generation
|
||||
from app.core.database import get_db
|
||||
from app.core.security import ApiPrincipal, require_api_key, require_operations_enabled
|
||||
from app.core.security import ApiPrincipal, require_api_key
|
||||
from app.modules.risk.constants import RiskEventActionKey, RiskGenerationResultKey
|
||||
from app.modules.risk.schemas import (
|
||||
RiskAssignRequest,
|
||||
@@ -94,7 +94,6 @@ def assign_risk_event(
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
require_operations_enabled()
|
||||
return RiskService(db).assign_event(
|
||||
event_id,
|
||||
assigned_to=payload.assigned_to,
|
||||
@@ -110,7 +109,6 @@ def comment_risk_event(
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
require_operations_enabled()
|
||||
return RiskService(db).comment_event(
|
||||
event_id,
|
||||
comment=payload.comment,
|
||||
@@ -126,7 +124,6 @@ def resolve_risk_event(
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
require_operations_enabled()
|
||||
return RiskService(db).resolve_event(
|
||||
event_id,
|
||||
comment=payload.comment,
|
||||
@@ -142,7 +139,6 @@ def close_risk_event(
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
require_operations_enabled()
|
||||
return RiskService(db).close_event(
|
||||
event_id,
|
||||
closed_reason=payload.closed_reason,
|
||||
@@ -158,7 +154,6 @@ def reopen_risk_event(
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
require_operations_enabled()
|
||||
return RiskService(db).reopen_event(
|
||||
event_id,
|
||||
comment=payload.comment,
|
||||
@@ -171,7 +166,6 @@ def generate_risk_events(
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
require_operations_enabled()
|
||||
return RiskService(db).generate_events(actor=principal.actor)
|
||||
|
||||
|
||||
@@ -179,5 +173,4 @@ def generate_risk_events(
|
||||
def enqueue_risk_events(
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
require_operations_enabled()
|
||||
return enqueue_risk_event_generation(actor=principal.actor)
|
||||
|
||||
@@ -2,7 +2,6 @@ from typing import Any
|
||||
|
||||
|
||||
from app.core.constants import ActorValue
|
||||
from app.core.security import ensure_business_mutations_enabled
|
||||
from app.core.utils.time import utc_now
|
||||
from app.modules.audit.constants import (
|
||||
AuditAction,
|
||||
@@ -182,7 +181,6 @@ class RiskActionMixin:
|
||||
comment: str | None,
|
||||
payload: dict[str, Any],
|
||||
) -> RiskEventAction:
|
||||
ensure_business_mutations_enabled()
|
||||
action_record = RiskEventAction(
|
||||
code=f"RISK-ACTION-{utc_now():%Y%m%d%H%M%S%f}",
|
||||
risk_event_id=record.id,
|
||||
|
||||
@@ -3,7 +3,6 @@ from typing import Any
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.constants import ActorValue
|
||||
from app.core.security import ensure_business_mutations_enabled
|
||||
from app.modules.audit.constants import (
|
||||
AuditAction,
|
||||
AuditRiskLevel,
|
||||
@@ -30,7 +29,6 @@ class RiskGenerationMixin:
|
||||
def generate_events(self, actor: str = ActorValue.API) -> dict[str, Any]:
|
||||
"""Generate or refresh risk-event ledger entries from current signals."""
|
||||
|
||||
ensure_business_mutations_enabled()
|
||||
payloads = self._build_event_payloads()
|
||||
created = 0
|
||||
updated = 0
|
||||
|
||||
22
app/modules/subscriptions/__init__.py
Normal file
22
app/modules/subscriptions/__init__.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from app.modules.subscriptions.models import PushDelivery, PushSubscription
|
||||
from app.modules.subscriptions.services import (
|
||||
DeliveryGenerationRequest,
|
||||
DeliverySendRequest,
|
||||
DeliveryService,
|
||||
NormalizedSchedule,
|
||||
SubscriptionManagementService,
|
||||
SubscriptionScanner,
|
||||
parse_schedule,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DeliveryGenerationRequest",
|
||||
"DeliverySendRequest",
|
||||
"DeliveryService",
|
||||
"NormalizedSchedule",
|
||||
"PushDelivery",
|
||||
"PushSubscription",
|
||||
"SubscriptionManagementService",
|
||||
"SubscriptionScanner",
|
||||
"parse_schedule",
|
||||
]
|
||||
65
app/modules/subscriptions/constants.py
Normal file
65
app/modules/subscriptions/constants.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class SubscriptionTargetType(StrEnum):
|
||||
USER = "user"
|
||||
CHAT = "chat"
|
||||
|
||||
|
||||
class SubscriptionScheduleType(StrEnum):
|
||||
ONCE = "once"
|
||||
DAILY = "daily"
|
||||
WEEKDAY = "weekday"
|
||||
WEEKLY = "weekly"
|
||||
MONTHLY = "monthly"
|
||||
INTERVAL = "interval"
|
||||
|
||||
|
||||
class PushSubscriptionStatus(StrEnum):
|
||||
ACTIVE = "active"
|
||||
PAUSED = "paused"
|
||||
CANCELLED = "cancelled"
|
||||
COMPLETED = "completed"
|
||||
|
||||
|
||||
class PushDeliveryStatus(StrEnum):
|
||||
PENDING = "pending"
|
||||
PROCESSING = "processing"
|
||||
RETRY = "retry"
|
||||
SENT = "sent"
|
||||
FAILED = "failed"
|
||||
SKIPPED = "skipped"
|
||||
|
||||
|
||||
class SubscriptionAuditAction(StrEnum):
|
||||
CREATE = "subscription.create"
|
||||
PAUSE = "subscription.pause"
|
||||
RESUME = "subscription.resume"
|
||||
CANCEL = "subscription.cancel"
|
||||
UPDATE_TIMEZONE = "subscription.update_timezone"
|
||||
UPDATE_QUIET_HOURS = "subscription.update_quiet_hours"
|
||||
DELIVERY_SENT = "subscription.delivery.sent"
|
||||
DELIVERY_FAILED = "subscription.delivery.failed"
|
||||
DELIVERY_SKIPPED = "subscription.delivery.skipped"
|
||||
|
||||
|
||||
DEFAULT_SUBSCRIPTION_TIMEZONE = "Asia/Shanghai"
|
||||
MAX_ACTIVE_SUBSCRIPTIONS = 50
|
||||
MAX_DAILY_DELIVERIES = 96
|
||||
MIN_INTERVAL_MINUTES = 15
|
||||
DELIVERY_RETRY_DELAYS_SECONDS = (60, 300, 900)
|
||||
SUBSCRIPTION_LEASE_SECONDS = 120
|
||||
DELIVERY_LEASE_SECONDS = 300
|
||||
|
||||
SUBSCRIPTION_NOT_FOUND = "Subscription not found"
|
||||
DELIVERY_NOT_FOUND = "Subscription delivery not found"
|
||||
SUBSCRIPTION_LIMIT_REACHED = "A user may enable at most 50 subscriptions"
|
||||
DAILY_DELIVERY_LIMIT_REACHED = "Daily delivery limit reached"
|
||||
INVALID_SCHEDULE = "Unsupported or invalid schedule expression"
|
||||
INVALID_TIMEZONE = "Invalid IANA timezone"
|
||||
INVALID_QUIET_HOURS = "Quiet hours must use different HH:MM start and end values"
|
||||
INVALID_PRIVATE_TARGET = "Private subscriptions must target the current user's open_id"
|
||||
INVALID_GROUP_TARGET = "Group subscriptions must be created by an administrator in the current group"
|
||||
INACTIVE_USER = "Feishu user is disabled"
|
||||
EMPTY_PROMPT = "Subscription prompt is required"
|
||||
|
||||
122
app/modules/subscriptions/models.py
Normal file
122
app/modules/subscriptions/models.py
Normal file
@@ -0,0 +1,122 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from app.core.utils.time import utc_now
|
||||
from app.modules.subscriptions.constants import (
|
||||
PushDeliveryStatus,
|
||||
PushSubscriptionStatus,
|
||||
)
|
||||
|
||||
|
||||
class PushSubscription(Base):
|
||||
__tablename__ = "push_subscriptions"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
code: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
owner_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("feishu_users.id", ondelete="CASCADE"),
|
||||
index=True,
|
||||
)
|
||||
target_type: Mapped[str] = mapped_column(String(16), index=True)
|
||||
target_id: Mapped[str] = mapped_column(String(256))
|
||||
prompt: Mapped[str] = mapped_column(Text)
|
||||
schedule_type: Mapped[str] = mapped_column(String(32), index=True)
|
||||
schedule_config: Mapped[dict[str, Any]] = mapped_column(JSON)
|
||||
timezone: Mapped[str] = mapped_column(String(64))
|
||||
next_run_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime,
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
default=PushSubscriptionStatus.ACTIVE,
|
||||
index=True,
|
||||
)
|
||||
consented_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now)
|
||||
last_run_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
locked_by: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
locked_until: Mapped[datetime | None] = mapped_column(
|
||||
DateTime,
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
default=utc_now,
|
||||
onupdate=utc_now,
|
||||
)
|
||||
|
||||
deliveries: Mapped[list["PushDelivery"]] = relationship(
|
||||
back_populates="subscription",
|
||||
cascade="all, delete-orphan",
|
||||
passive_deletes=True,
|
||||
)
|
||||
|
||||
|
||||
class PushDelivery(Base):
|
||||
__tablename__ = "push_deliveries"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"subscription_id",
|
||||
"scheduled_for",
|
||||
name="uq_push_delivery_subscription_schedule",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
code: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
subscription_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("push_subscriptions.id", ondelete="CASCADE"),
|
||||
index=True,
|
||||
)
|
||||
scheduled_for: Mapped[datetime] = mapped_column(DateTime, index=True)
|
||||
idempotency_key: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
message_uuid: Mapped[str] = mapped_column(String(36), unique=True, index=True)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
default=PushDeliveryStatus.PENDING,
|
||||
index=True,
|
||||
)
|
||||
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
next_attempt_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime,
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
rendered_content: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
provider_message_id: Mapped[str | None] = mapped_column(
|
||||
String(256),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
locked_by: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
locked_until: Mapped[datetime | None] = mapped_column(
|
||||
DateTime,
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
sent_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
default=utc_now,
|
||||
onupdate=utc_now,
|
||||
)
|
||||
|
||||
subscription: Mapped[PushSubscription] = relationship(back_populates="deliveries")
|
||||
|
||||
49
app/modules/subscriptions/routes.py
Normal file
49
app/modules/subscriptions/routes.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import require_api_key
|
||||
from app.modules.subscriptions.schemas import PushDeliveryRead, PushSubscriptionRead
|
||||
from app.modules.subscriptions.services.management import SubscriptionManagementService
|
||||
|
||||
router = APIRouter(dependencies=[Depends(require_api_key)])
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_subscriptions(
|
||||
status_filter: str | None = None,
|
||||
owner_id: int | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
total, records = SubscriptionManagementService(db).list_all(
|
||||
status_filter=status_filter,
|
||||
owner_id=owner_id,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return {
|
||||
"total": total,
|
||||
"items": [PushSubscriptionRead.model_validate(item) for item in records],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/deliveries")
|
||||
def list_deliveries(
|
||||
status_filter: str | None = None,
|
||||
subscription_code: str | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
total, records = SubscriptionManagementService(db).list_deliveries(
|
||||
status_filter=status_filter,
|
||||
subscription_code=subscription_code,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return {
|
||||
"total": total,
|
||||
"items": [PushDeliveryRead.model_validate(item) for item in records],
|
||||
}
|
||||
55
app/modules/subscriptions/schemas.py
Normal file
55
app/modules/subscriptions/schemas.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class NormalizedScheduleRead(BaseModel):
|
||||
schedule_type: str
|
||||
schedule_config: dict[str, Any]
|
||||
timezone: str
|
||||
next_run_at: datetime
|
||||
display: str
|
||||
|
||||
|
||||
class SubscriptionCreate(BaseModel):
|
||||
prompt: str = Field(min_length=1, max_length=8000)
|
||||
schedule: str = Field(min_length=1, max_length=256)
|
||||
|
||||
|
||||
class PushSubscriptionRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
code: str
|
||||
owner_id: int
|
||||
target_type: str
|
||||
target_id: str
|
||||
prompt: str
|
||||
schedule_type: str
|
||||
schedule_config: dict[str, Any]
|
||||
timezone: str
|
||||
next_run_at: datetime | None
|
||||
status: str
|
||||
consented_at: datetime
|
||||
last_run_at: datetime | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class PushDeliveryRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
code: str
|
||||
subscription_id: int
|
||||
scheduled_for: datetime
|
||||
idempotency_key: str
|
||||
message_uuid: str
|
||||
status: str
|
||||
attempt_count: int
|
||||
next_attempt_at: datetime | None
|
||||
provider_message_id: str | None
|
||||
last_error: str | None
|
||||
sent_at: datetime | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
39
app/modules/subscriptions/services/__init__.py
Normal file
39
app/modules/subscriptions/services/__init__.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from app.modules.subscriptions.services.delivery import (
|
||||
DeliveryGenerationRequest,
|
||||
DeliveryGenerator,
|
||||
DeliverySendRequest,
|
||||
DeliverySender,
|
||||
DeliveryService,
|
||||
PermanentDeliveryError,
|
||||
RetryableDeliveryError,
|
||||
)
|
||||
from app.modules.subscriptions.services.management import SubscriptionManagementService
|
||||
from app.modules.subscriptions.services.scanner import SubscriptionScanner
|
||||
from app.modules.subscriptions.services.schedule import (
|
||||
NormalizedSchedule,
|
||||
ScheduleParseError,
|
||||
is_in_quiet_hours,
|
||||
next_occurrence,
|
||||
next_quiet_end,
|
||||
parse_schedule,
|
||||
validate_timezone,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DeliveryGenerationRequest",
|
||||
"DeliveryGenerator",
|
||||
"DeliverySendRequest",
|
||||
"DeliverySender",
|
||||
"DeliveryService",
|
||||
"NormalizedSchedule",
|
||||
"PermanentDeliveryError",
|
||||
"RetryableDeliveryError",
|
||||
"ScheduleParseError",
|
||||
"SubscriptionManagementService",
|
||||
"SubscriptionScanner",
|
||||
"is_in_quiet_hours",
|
||||
"next_occurrence",
|
||||
"next_quiet_end",
|
||||
"parse_schedule",
|
||||
"validate_timezone",
|
||||
]
|
||||
645
app/modules/subscriptions/services/delivery.py
Normal file
645
app/modules/subscriptions/services/delivery.py
Normal file
@@ -0,0 +1,645 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, time, timedelta
|
||||
from typing import Any, Protocol
|
||||
from uuid import uuid4
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import and_, func, or_, select, update
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.http.pagination import bounded_limit
|
||||
from app.core.utils.time import utc_now
|
||||
from app.modules.audit.schemas import AuditLogCreate
|
||||
from app.modules.audit.service import AuditService
|
||||
from app.modules.feishu.errors import FeishuAPIError
|
||||
from app.modules.feishu_users.constants import FeishuUserRole, FeishuUserStatus
|
||||
from app.modules.feishu_users.models import FeishuUser
|
||||
from app.modules.subscriptions.constants import (
|
||||
DAILY_DELIVERY_LIMIT_REACHED,
|
||||
DELIVERY_LEASE_SECONDS,
|
||||
DELIVERY_NOT_FOUND,
|
||||
DELIVERY_RETRY_DELAYS_SECONDS,
|
||||
MAX_DAILY_DELIVERIES,
|
||||
PushDeliveryStatus,
|
||||
PushSubscriptionStatus,
|
||||
SubscriptionAuditAction,
|
||||
SubscriptionTargetType,
|
||||
)
|
||||
from app.modules.subscriptions.models import PushDelivery, PushSubscription
|
||||
from app.modules.subscriptions.services.schedule import (
|
||||
is_in_quiet_hours,
|
||||
next_quiet_end,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DeliveryGenerationRequest:
|
||||
prompt: str
|
||||
owner_id: int | None
|
||||
use_personal_context: bool
|
||||
use_company_rules: bool
|
||||
allow_tools: bool = False
|
||||
record_history: bool = False
|
||||
infer_preferences: bool = False
|
||||
write_memory: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DeliverySendRequest:
|
||||
receive_id: str
|
||||
receive_id_type: str
|
||||
tenant_key: str
|
||||
text: str
|
||||
uuid: str
|
||||
|
||||
|
||||
class DeliveryGenerator(Protocol):
|
||||
def generate(self, request: DeliveryGenerationRequest) -> str:
|
||||
"""Generate delivery text without side effects or business-data tools."""
|
||||
|
||||
|
||||
class DeliverySender(Protocol):
|
||||
def send(self, request: DeliverySendRequest) -> dict[str, Any]:
|
||||
"""Send a message and return the provider response."""
|
||||
|
||||
|
||||
class RetryableDeliveryError(RuntimeError):
|
||||
"""A temporary generation or provider failure."""
|
||||
|
||||
|
||||
class PermanentDeliveryError(RuntimeError):
|
||||
"""A delivery failure that must not be retried."""
|
||||
|
||||
|
||||
class DeliveryService:
|
||||
"""Process durable deliveries with fencing and database-driven retry timing."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db: Session,
|
||||
*,
|
||||
generator: DeliveryGenerator,
|
||||
sender: DeliverySender,
|
||||
lease_seconds: int = DELIVERY_LEASE_SECONDS,
|
||||
):
|
||||
self.db = db
|
||||
self.generator = generator
|
||||
self.sender = sender
|
||||
self.lease_seconds = lease_seconds
|
||||
|
||||
def process(
|
||||
self,
|
||||
delivery_code: str,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
worker_id: str = "subscription-delivery",
|
||||
) -> PushDelivery:
|
||||
current = _naive_utc(now or utc_now())
|
||||
existing = self._get(delivery_code)
|
||||
if existing.status in {
|
||||
PushDeliveryStatus.SENT,
|
||||
PushDeliveryStatus.FAILED,
|
||||
PushDeliveryStatus.SKIPPED,
|
||||
}:
|
||||
return existing
|
||||
|
||||
lock_owner = f"{worker_id}:{uuid4().hex}"
|
||||
if not self._claim(existing.id, lock_owner, current):
|
||||
self.db.rollback()
|
||||
return self._get(delivery_code)
|
||||
delivery = self._get(delivery_code)
|
||||
subscription, owner = self._load_context(delivery.subscription_id)
|
||||
|
||||
skip_reason = self._skip_reason(subscription, owner)
|
||||
if skip_reason is not None:
|
||||
return self._finish_skipped(delivery, lock_owner, owner, skip_reason)
|
||||
|
||||
if (
|
||||
owner is not None
|
||||
and is_in_quiet_hours(
|
||||
current,
|
||||
owner.timezone,
|
||||
owner.quiet_hours_start,
|
||||
owner.quiet_hours_end,
|
||||
)
|
||||
):
|
||||
quiet_end = next_quiet_end(
|
||||
current,
|
||||
owner.timezone,
|
||||
owner.quiet_hours_start,
|
||||
owner.quiet_hours_end,
|
||||
)
|
||||
return self._defer_for_quiet_hours(delivery, lock_owner, quiet_end)
|
||||
|
||||
if not self._start_attempt(delivery.id, lock_owner, current):
|
||||
self.db.rollback()
|
||||
return self._get(delivery_code)
|
||||
delivery = self._get(delivery_code)
|
||||
try:
|
||||
content = delivery.rendered_content
|
||||
if content is None:
|
||||
content = str(
|
||||
self.generator.generate(
|
||||
self._generation_request(subscription)
|
||||
)
|
||||
).strip()
|
||||
if not content:
|
||||
raise PermanentDeliveryError("Delivery generator returned empty content")
|
||||
self._save_content(delivery.id, lock_owner, content, current)
|
||||
subscription, owner = self._lock_send_context(delivery.subscription_id)
|
||||
skip_reason = self._skip_reason(subscription, owner)
|
||||
if skip_reason is None and owner is not None:
|
||||
if self._sent_today(owner, current) >= MAX_DAILY_DELIVERIES:
|
||||
skip_reason = DAILY_DELIVERY_LIMIT_REACHED
|
||||
if skip_reason is not None:
|
||||
return self._finish_skipped(
|
||||
delivery,
|
||||
lock_owner,
|
||||
owner,
|
||||
skip_reason,
|
||||
)
|
||||
response = self.sender.send(
|
||||
DeliverySendRequest(
|
||||
receive_id=(
|
||||
owner.open_id
|
||||
if subscription.target_type == SubscriptionTargetType.USER
|
||||
else subscription.target_id
|
||||
),
|
||||
receive_id_type=(
|
||||
"open_id"
|
||||
if subscription.target_type == SubscriptionTargetType.USER
|
||||
else "chat_id"
|
||||
),
|
||||
tenant_key=owner.tenant_key,
|
||||
text=content,
|
||||
uuid=delivery.message_uuid,
|
||||
)
|
||||
)
|
||||
self._validate_provider_response(response)
|
||||
except Exception as exc:
|
||||
self.db.rollback()
|
||||
return self._finish_failure(delivery_code, lock_owner, current, exc)
|
||||
return self._finish_sent(
|
||||
delivery_code,
|
||||
lock_owner,
|
||||
current,
|
||||
owner,
|
||||
response,
|
||||
)
|
||||
|
||||
def process_due(
|
||||
self,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
limit: int = 100,
|
||||
worker_id: str = "subscription-delivery",
|
||||
) -> list[PushDelivery]:
|
||||
current = _naive_utc(now or utc_now())
|
||||
stmt = (
|
||||
select(PushDelivery.code)
|
||||
.where(
|
||||
or_(
|
||||
and_(
|
||||
PushDelivery.status.in_(
|
||||
[
|
||||
PushDeliveryStatus.PENDING,
|
||||
PushDeliveryStatus.RETRY,
|
||||
]
|
||||
),
|
||||
PushDelivery.next_attempt_at.is_not(None),
|
||||
PushDelivery.next_attempt_at <= current,
|
||||
),
|
||||
and_(
|
||||
PushDelivery.status == PushDeliveryStatus.PROCESSING,
|
||||
PushDelivery.locked_until.is_not(None),
|
||||
PushDelivery.locked_until <= current,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by(PushDelivery.next_attempt_at.asc(), PushDelivery.id.asc())
|
||||
.limit(bounded_limit(limit))
|
||||
)
|
||||
codes = list(self.db.execute(stmt).scalars())
|
||||
return [
|
||||
self.process(
|
||||
code,
|
||||
now=current,
|
||||
worker_id=worker_id,
|
||||
)
|
||||
for code in codes
|
||||
]
|
||||
|
||||
def _claim(self, delivery_id: int, lock_owner: str, current: datetime) -> bool:
|
||||
result = self.db.execute(
|
||||
update(PushDelivery)
|
||||
.where(
|
||||
PushDelivery.id == delivery_id,
|
||||
or_(
|
||||
and_(
|
||||
PushDelivery.status.in_(
|
||||
[
|
||||
PushDeliveryStatus.PENDING,
|
||||
PushDeliveryStatus.RETRY,
|
||||
]
|
||||
),
|
||||
PushDelivery.next_attempt_at.is_not(None),
|
||||
PushDelivery.next_attempt_at <= current,
|
||||
),
|
||||
and_(
|
||||
PushDelivery.status == PushDeliveryStatus.PROCESSING,
|
||||
PushDelivery.locked_until.is_not(None),
|
||||
PushDelivery.locked_until <= current,
|
||||
),
|
||||
),
|
||||
or_(
|
||||
PushDelivery.locked_until.is_(None),
|
||||
PushDelivery.locked_until <= current,
|
||||
),
|
||||
)
|
||||
.values(
|
||||
status=PushDeliveryStatus.PROCESSING,
|
||||
locked_by=lock_owner,
|
||||
locked_until=current + timedelta(seconds=self.lease_seconds),
|
||||
)
|
||||
.execution_options(synchronize_session=False)
|
||||
)
|
||||
self.db.commit()
|
||||
return result.rowcount == 1
|
||||
|
||||
def _start_attempt(
|
||||
self,
|
||||
delivery_id: int,
|
||||
lock_owner: str,
|
||||
current: datetime,
|
||||
) -> bool:
|
||||
result = self.db.execute(
|
||||
update(PushDelivery)
|
||||
.where(
|
||||
PushDelivery.id == delivery_id,
|
||||
PushDelivery.status == PushDeliveryStatus.PROCESSING,
|
||||
PushDelivery.locked_by == lock_owner,
|
||||
)
|
||||
.values(
|
||||
attempt_count=PushDelivery.attempt_count + 1,
|
||||
locked_until=current + timedelta(seconds=self.lease_seconds),
|
||||
)
|
||||
.execution_options(synchronize_session=False)
|
||||
)
|
||||
self.db.commit()
|
||||
return result.rowcount == 1
|
||||
|
||||
def _save_content(
|
||||
self,
|
||||
delivery_id: int,
|
||||
lock_owner: str,
|
||||
content: str,
|
||||
current: datetime,
|
||||
) -> None:
|
||||
result = self.db.execute(
|
||||
update(PushDelivery)
|
||||
.where(
|
||||
PushDelivery.id == delivery_id,
|
||||
PushDelivery.status == PushDeliveryStatus.PROCESSING,
|
||||
PushDelivery.locked_by == lock_owner,
|
||||
)
|
||||
.values(
|
||||
rendered_content=content,
|
||||
locked_until=current + timedelta(seconds=self.lease_seconds),
|
||||
)
|
||||
.execution_options(synchronize_session=False)
|
||||
)
|
||||
if result.rowcount != 1:
|
||||
self.db.rollback()
|
||||
raise RetryableDeliveryError("Delivery lease was lost")
|
||||
self.db.commit()
|
||||
|
||||
def _finish_sent(
|
||||
self,
|
||||
delivery_code: str,
|
||||
lock_owner: str,
|
||||
current: datetime,
|
||||
owner: FeishuUser | None,
|
||||
response: dict[str, Any],
|
||||
) -> PushDelivery:
|
||||
provider_message_id = _provider_message_id(response)
|
||||
result = self.db.execute(
|
||||
update(PushDelivery)
|
||||
.where(
|
||||
PushDelivery.code == delivery_code,
|
||||
PushDelivery.status == PushDeliveryStatus.PROCESSING,
|
||||
PushDelivery.locked_by == lock_owner,
|
||||
)
|
||||
.values(
|
||||
status=PushDeliveryStatus.SENT,
|
||||
next_attempt_at=None,
|
||||
provider_message_id=provider_message_id,
|
||||
last_error=None,
|
||||
sent_at=current,
|
||||
locked_by=None,
|
||||
locked_until=None,
|
||||
)
|
||||
.execution_options(synchronize_session=False)
|
||||
)
|
||||
if result.rowcount != 1:
|
||||
self.db.rollback()
|
||||
return self._get(delivery_code)
|
||||
record = self._get(delivery_code)
|
||||
self._audit(
|
||||
owner,
|
||||
record,
|
||||
SubscriptionAuditAction.DELIVERY_SENT,
|
||||
{"status": PushDeliveryStatus.SENT},
|
||||
)
|
||||
self.db.commit()
|
||||
self.db.refresh(record)
|
||||
return record
|
||||
|
||||
def _finish_failure(
|
||||
self,
|
||||
delivery_code: str,
|
||||
lock_owner: str,
|
||||
current: datetime,
|
||||
exc: Exception,
|
||||
) -> PushDelivery:
|
||||
record = self._get(delivery_code)
|
||||
retryable = _is_retryable(exc)
|
||||
retry_index = record.attempt_count - 1
|
||||
will_retry = retryable and 0 <= retry_index < len(
|
||||
DELIVERY_RETRY_DELAYS_SECONDS
|
||||
)
|
||||
next_attempt_at = (
|
||||
current + timedelta(seconds=DELIVERY_RETRY_DELAYS_SECONDS[retry_index])
|
||||
if will_retry
|
||||
else None
|
||||
)
|
||||
result = self.db.execute(
|
||||
update(PushDelivery)
|
||||
.where(
|
||||
PushDelivery.code == delivery_code,
|
||||
PushDelivery.status == PushDeliveryStatus.PROCESSING,
|
||||
PushDelivery.locked_by == lock_owner,
|
||||
)
|
||||
.values(
|
||||
status=(
|
||||
PushDeliveryStatus.RETRY
|
||||
if will_retry
|
||||
else PushDeliveryStatus.FAILED
|
||||
),
|
||||
next_attempt_at=next_attempt_at,
|
||||
last_error=f"{type(exc).__name__}: {exc}"[:2000],
|
||||
locked_by=None,
|
||||
locked_until=None,
|
||||
)
|
||||
.execution_options(synchronize_session=False)
|
||||
)
|
||||
if result.rowcount != 1:
|
||||
self.db.rollback()
|
||||
return self._get(delivery_code)
|
||||
record = self._get(delivery_code)
|
||||
if not will_retry:
|
||||
_, owner = self._load_context(record.subscription_id)
|
||||
self._audit(
|
||||
owner,
|
||||
record,
|
||||
SubscriptionAuditAction.DELIVERY_FAILED,
|
||||
{
|
||||
"status": PushDeliveryStatus.FAILED,
|
||||
"attempt_count": record.attempt_count,
|
||||
},
|
||||
)
|
||||
self.db.commit()
|
||||
self.db.refresh(record)
|
||||
return record
|
||||
|
||||
def _finish_skipped(
|
||||
self,
|
||||
delivery: PushDelivery,
|
||||
lock_owner: str,
|
||||
owner: FeishuUser | None,
|
||||
reason: str,
|
||||
) -> PushDelivery:
|
||||
result = self.db.execute(
|
||||
update(PushDelivery)
|
||||
.where(
|
||||
PushDelivery.id == delivery.id,
|
||||
PushDelivery.status == PushDeliveryStatus.PROCESSING,
|
||||
PushDelivery.locked_by == lock_owner,
|
||||
)
|
||||
.values(
|
||||
status=PushDeliveryStatus.SKIPPED,
|
||||
next_attempt_at=None,
|
||||
last_error=reason,
|
||||
locked_by=None,
|
||||
locked_until=None,
|
||||
)
|
||||
.execution_options(synchronize_session=False)
|
||||
)
|
||||
if result.rowcount != 1:
|
||||
self.db.rollback()
|
||||
return self._get(delivery.code)
|
||||
record = self._get(delivery.code)
|
||||
self._audit(
|
||||
owner,
|
||||
record,
|
||||
SubscriptionAuditAction.DELIVERY_SKIPPED,
|
||||
{"status": PushDeliveryStatus.SKIPPED, "reason": reason},
|
||||
)
|
||||
self.db.commit()
|
||||
self.db.refresh(record)
|
||||
return record
|
||||
|
||||
def _defer_for_quiet_hours(
|
||||
self,
|
||||
delivery: PushDelivery,
|
||||
lock_owner: str,
|
||||
quiet_end: datetime,
|
||||
) -> PushDelivery:
|
||||
status_value = (
|
||||
PushDeliveryStatus.PENDING
|
||||
if delivery.attempt_count == 0
|
||||
else PushDeliveryStatus.RETRY
|
||||
)
|
||||
result = self.db.execute(
|
||||
update(PushDelivery)
|
||||
.where(
|
||||
PushDelivery.id == delivery.id,
|
||||
PushDelivery.status == PushDeliveryStatus.PROCESSING,
|
||||
PushDelivery.locked_by == lock_owner,
|
||||
)
|
||||
.values(
|
||||
status=status_value,
|
||||
next_attempt_at=quiet_end,
|
||||
locked_by=None,
|
||||
locked_until=None,
|
||||
)
|
||||
.execution_options(synchronize_session=False)
|
||||
)
|
||||
if result.rowcount != 1:
|
||||
self.db.rollback()
|
||||
return self._get(delivery.code)
|
||||
self.db.commit()
|
||||
return self._get(delivery.code)
|
||||
|
||||
def _generation_request(
|
||||
self,
|
||||
subscription: PushSubscription,
|
||||
) -> DeliveryGenerationRequest:
|
||||
personal = subscription.target_type == SubscriptionTargetType.USER
|
||||
return DeliveryGenerationRequest(
|
||||
prompt=subscription.prompt,
|
||||
owner_id=subscription.owner_id if personal else None,
|
||||
use_personal_context=personal,
|
||||
use_company_rules=not personal,
|
||||
)
|
||||
|
||||
def _skip_reason(
|
||||
self,
|
||||
subscription: PushSubscription | None,
|
||||
owner: FeishuUser | None,
|
||||
) -> str | None:
|
||||
if subscription is None:
|
||||
return "Subscription was removed"
|
||||
if owner is None or owner.status != FeishuUserStatus.ACTIVE:
|
||||
return "Feishu user is disabled"
|
||||
if subscription.status not in {
|
||||
PushSubscriptionStatus.ACTIVE,
|
||||
PushSubscriptionStatus.COMPLETED,
|
||||
}:
|
||||
return "Subscription is not active"
|
||||
if subscription.target_type == SubscriptionTargetType.USER:
|
||||
if subscription.target_id != owner.open_id:
|
||||
return "Private subscription target no longer matches its owner"
|
||||
return None
|
||||
if subscription.target_type == SubscriptionTargetType.CHAT:
|
||||
if owner.role != FeishuUserRole.ADMIN or not subscription.target_id:
|
||||
return "Group subscription is no longer authorized"
|
||||
return None
|
||||
return "Unsupported subscription target"
|
||||
|
||||
def _load_context(
|
||||
self,
|
||||
subscription_id: int,
|
||||
) -> tuple[PushSubscription | None, FeishuUser | None]:
|
||||
subscription = self.db.get(PushSubscription, subscription_id)
|
||||
owner = (
|
||||
self.db.get(FeishuUser, subscription.owner_id)
|
||||
if subscription is not None
|
||||
else None
|
||||
)
|
||||
return subscription, owner
|
||||
|
||||
def _lock_send_context(
|
||||
self,
|
||||
subscription_id: int,
|
||||
) -> tuple[PushSubscription | None, FeishuUser | None]:
|
||||
"""Recheck authorization and serialize the final per-owner send decision."""
|
||||
|
||||
subscription = self.db.execute(
|
||||
select(PushSubscription)
|
||||
.where(PushSubscription.id == subscription_id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
).scalar_one_or_none()
|
||||
owner = (
|
||||
self.db.execute(
|
||||
select(FeishuUser)
|
||||
.where(FeishuUser.id == subscription.owner_id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
).scalar_one_or_none()
|
||||
if subscription is not None
|
||||
else None
|
||||
)
|
||||
return subscription, owner
|
||||
|
||||
def _sent_today(self, owner: FeishuUser, current: datetime) -> int:
|
||||
zone = ZoneInfo(owner.timezone)
|
||||
local_now = current.replace(tzinfo=UTC).astimezone(zone)
|
||||
local_start = datetime.combine(local_now.date(), time.min, tzinfo=zone)
|
||||
local_end = local_start + timedelta(days=1)
|
||||
start_utc = local_start.astimezone(UTC).replace(tzinfo=None)
|
||||
end_utc = local_end.astimezone(UTC).replace(tzinfo=None)
|
||||
return int(
|
||||
self.db.scalar(
|
||||
select(func.count())
|
||||
.select_from(PushDelivery)
|
||||
.join(PushSubscription)
|
||||
.where(
|
||||
PushSubscription.owner_id == owner.id,
|
||||
PushDelivery.status == PushDeliveryStatus.SENT,
|
||||
PushDelivery.sent_at >= start_utc,
|
||||
PushDelivery.sent_at < end_utc,
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
def _get(self, delivery_code: str) -> PushDelivery:
|
||||
record = self.db.execute(
|
||||
select(PushDelivery).where(PushDelivery.code == delivery_code)
|
||||
).scalar_one_or_none()
|
||||
if record is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=DELIVERY_NOT_FOUND,
|
||||
)
|
||||
return record
|
||||
|
||||
def _audit(
|
||||
self,
|
||||
owner: FeishuUser | None,
|
||||
delivery: PushDelivery,
|
||||
action: str,
|
||||
response: dict[str, Any],
|
||||
) -> None:
|
||||
AuditService(self.db).record(
|
||||
AuditLogCreate(
|
||||
actor=owner.code if owner is not None else "subscription-system",
|
||||
source="subscriptions",
|
||||
action=action,
|
||||
target_type="push-delivery",
|
||||
target_id=delivery.code,
|
||||
response_payload=response,
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _validate_provider_response(response: dict[str, Any]) -> None:
|
||||
if not isinstance(response, dict):
|
||||
raise RetryableDeliveryError("Message provider returned an invalid response")
|
||||
if "code" in response and response.get("code") != 0:
|
||||
raise RetryableDeliveryError(
|
||||
f"Message provider returned business code {response.get('code')}"
|
||||
)
|
||||
|
||||
|
||||
def _provider_message_id(response: dict[str, Any]) -> str | None:
|
||||
direct = response.get("message_id")
|
||||
nested = response.get("data")
|
||||
value = direct or (nested.get("message_id") if isinstance(nested, dict) else None)
|
||||
return str(value) if value is not None else None
|
||||
|
||||
|
||||
def _is_retryable(exc: Exception) -> bool:
|
||||
if isinstance(exc, PermanentDeliveryError):
|
||||
return False
|
||||
if isinstance(exc, RetryableDeliveryError):
|
||||
return True
|
||||
if isinstance(exc, FeishuAPIError):
|
||||
return exc.retryable
|
||||
if isinstance(exc, httpx.HTTPStatusError):
|
||||
code = exc.response.status_code
|
||||
return code == 429 or code >= 500
|
||||
if isinstance(exc, (httpx.TimeoutException, httpx.NetworkError)):
|
||||
return True
|
||||
if isinstance(exc, HTTPException):
|
||||
return exc.status_code == 429 or exc.status_code >= 500
|
||||
return True
|
||||
|
||||
|
||||
def _naive_utc(value: datetime) -> datetime:
|
||||
if value.tzinfo is None:
|
||||
return value
|
||||
return value.astimezone(UTC).replace(tzinfo=None)
|
||||
498
app/modules/subscriptions/services/management.py
Normal file
498
app/modules/subscriptions/services/management.py
Normal file
@@ -0,0 +1,498 @@
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.http.pagination import bounded_limit, bounded_offset
|
||||
from app.core.utils.time import utc_now
|
||||
from app.modules.audit.schemas import AuditLogCreate
|
||||
from app.modules.audit.service import AuditService
|
||||
from app.modules.feishu_users.constants import (
|
||||
FeishuCapability,
|
||||
FeishuUserRole,
|
||||
FeishuUserStatus,
|
||||
)
|
||||
from app.modules.feishu_users.models import FeishuUser
|
||||
from app.modules.feishu_users.principal import FeishuPrincipal
|
||||
from app.modules.subscriptions.constants import (
|
||||
EMPTY_PROMPT,
|
||||
INVALID_GROUP_TARGET,
|
||||
INVALID_QUIET_HOURS,
|
||||
MAX_ACTIVE_SUBSCRIPTIONS,
|
||||
PushSubscriptionStatus,
|
||||
SUBSCRIPTION_LIMIT_REACHED,
|
||||
SUBSCRIPTION_NOT_FOUND,
|
||||
SubscriptionAuditAction,
|
||||
SubscriptionScheduleType,
|
||||
SubscriptionTargetType,
|
||||
)
|
||||
from app.modules.subscriptions.models import PushDelivery, PushSubscription
|
||||
from app.modules.subscriptions.services.schedule import (
|
||||
NormalizedSchedule,
|
||||
ScheduleParseError,
|
||||
next_occurrence,
|
||||
parse_quiet_clock,
|
||||
parse_schedule,
|
||||
validate_timezone,
|
||||
)
|
||||
|
||||
|
||||
class SubscriptionManagementService:
|
||||
"""Manage subscriptions only through authenticated Feishu principals."""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def create_private(
|
||||
self,
|
||||
principal: FeishuPrincipal,
|
||||
schedule_expression: str,
|
||||
prompt: str,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
) -> tuple[PushSubscription, NormalizedSchedule]:
|
||||
principal.require_capability(FeishuCapability.PRIVATE_SUBSCRIPTION)
|
||||
owner = self._active_owner(principal, for_update=True)
|
||||
return self._create(
|
||||
owner=owner,
|
||||
target_type=SubscriptionTargetType.USER,
|
||||
target_id=owner.open_id,
|
||||
schedule_expression=schedule_expression,
|
||||
prompt=prompt,
|
||||
now=now,
|
||||
)
|
||||
|
||||
def create_group(
|
||||
self,
|
||||
principal: FeishuPrincipal,
|
||||
schedule_expression: str,
|
||||
prompt: str,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
) -> tuple[PushSubscription, NormalizedSchedule]:
|
||||
principal.require_capability(FeishuCapability.GROUP_SUBSCRIPTION)
|
||||
owner = self._active_owner(principal, for_update=True)
|
||||
if (
|
||||
owner.role != FeishuUserRole.ADMIN
|
||||
or not principal.chat_id
|
||||
or principal.chat_type not in {"group", "group_chat"}
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=INVALID_GROUP_TARGET,
|
||||
)
|
||||
return self._create(
|
||||
owner=owner,
|
||||
target_type=SubscriptionTargetType.CHAT,
|
||||
target_id=principal.chat_id,
|
||||
schedule_expression=schedule_expression,
|
||||
prompt=prompt,
|
||||
now=now,
|
||||
)
|
||||
|
||||
def list_for_owner(self, principal: FeishuPrincipal) -> list[PushSubscription]:
|
||||
principal.require_active()
|
||||
return list(
|
||||
self.db.execute(
|
||||
select(PushSubscription)
|
||||
.where(PushSubscription.owner_id == principal.owner_id)
|
||||
.order_by(PushSubscription.id.desc())
|
||||
).scalars()
|
||||
)
|
||||
|
||||
def latest_deliveries_for_owner(
|
||||
self,
|
||||
principal: FeishuPrincipal,
|
||||
) -> dict[int, PushDelivery]:
|
||||
"""Return at most one latest delivery per owner-scoped subscription."""
|
||||
|
||||
principal.require_active()
|
||||
latest_ids = (
|
||||
select(func.max(PushDelivery.id))
|
||||
.join(PushSubscription)
|
||||
.where(PushSubscription.owner_id == principal.owner_id)
|
||||
.group_by(PushDelivery.subscription_id)
|
||||
)
|
||||
records = self.db.execute(
|
||||
select(PushDelivery).where(PushDelivery.id.in_(latest_ids))
|
||||
).scalars()
|
||||
return {record.subscription_id: record for record in records}
|
||||
|
||||
def pause(self, principal: FeishuPrincipal, code: str) -> PushSubscription:
|
||||
principal.require_capability(FeishuCapability.PRIVATE_SUBSCRIPTION)
|
||||
record = self._owned_subscription(principal.owner_id, code, for_update=True)
|
||||
if record.status == PushSubscriptionStatus.ACTIVE:
|
||||
record.status = PushSubscriptionStatus.PAUSED
|
||||
self._audit(principal, SubscriptionAuditAction.PAUSE, record)
|
||||
self.db.commit()
|
||||
self.db.refresh(record)
|
||||
return record
|
||||
|
||||
def resume(
|
||||
self,
|
||||
principal: FeishuPrincipal,
|
||||
code: str,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
) -> PushSubscription:
|
||||
principal.require_capability(FeishuCapability.PRIVATE_SUBSCRIPTION)
|
||||
self._active_owner(principal, for_update=True)
|
||||
record = self._owned_subscription(principal.owner_id, code, for_update=True)
|
||||
if record.status != PushSubscriptionStatus.PAUSED:
|
||||
return record
|
||||
self._ensure_active_capacity(principal.owner_id)
|
||||
current = _naive_utc(now or utc_now())
|
||||
if record.next_run_at is None or record.next_run_at <= current:
|
||||
next_run = next_occurrence(
|
||||
record.schedule_type,
|
||||
record.schedule_config,
|
||||
record.timezone,
|
||||
after=current,
|
||||
)
|
||||
if next_run is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Expired one-time subscriptions cannot be resumed",
|
||||
)
|
||||
record.next_run_at = next_run
|
||||
record.status = PushSubscriptionStatus.ACTIVE
|
||||
self._audit(principal, SubscriptionAuditAction.RESUME, record)
|
||||
self.db.commit()
|
||||
self.db.refresh(record)
|
||||
return record
|
||||
|
||||
def cancel(self, principal: FeishuPrincipal, code: str) -> PushSubscription:
|
||||
principal.require_capability(FeishuCapability.PRIVATE_SUBSCRIPTION)
|
||||
record = self._owned_subscription(principal.owner_id, code, for_update=True)
|
||||
if record.status != PushSubscriptionStatus.CANCELLED:
|
||||
record.status = PushSubscriptionStatus.CANCELLED
|
||||
record.next_run_at = None
|
||||
self._audit(principal, SubscriptionAuditAction.CANCEL, record)
|
||||
self.db.commit()
|
||||
self.db.refresh(record)
|
||||
return record
|
||||
|
||||
def set_timezone(
|
||||
self,
|
||||
principal: FeishuPrincipal,
|
||||
timezone_name: str,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
) -> FeishuUser:
|
||||
principal.require_active()
|
||||
try:
|
||||
validate_timezone(timezone_name)
|
||||
except ScheduleParseError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
owner = self._active_owner(principal, for_update=True)
|
||||
owner.timezone = timezone_name
|
||||
current = _naive_utc(now or utc_now())
|
||||
subscriptions = list(
|
||||
self.db.execute(
|
||||
select(PushSubscription).where(
|
||||
PushSubscription.owner_id == owner.id,
|
||||
PushSubscription.status.in_(
|
||||
[
|
||||
PushSubscriptionStatus.ACTIVE,
|
||||
PushSubscriptionStatus.PAUSED,
|
||||
]
|
||||
),
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
for subscription in subscriptions:
|
||||
subscription.timezone = timezone_name
|
||||
if (
|
||||
subscription.status == PushSubscriptionStatus.ACTIVE
|
||||
and subscription.schedule_type
|
||||
not in {
|
||||
SubscriptionScheduleType.ONCE,
|
||||
SubscriptionScheduleType.INTERVAL,
|
||||
}
|
||||
):
|
||||
subscription.next_run_at = next_occurrence(
|
||||
subscription.schedule_type,
|
||||
subscription.schedule_config,
|
||||
timezone_name,
|
||||
after=current,
|
||||
)
|
||||
self._audit_user(
|
||||
principal,
|
||||
SubscriptionAuditAction.UPDATE_TIMEZONE,
|
||||
{"timezone": timezone_name},
|
||||
)
|
||||
self.db.commit()
|
||||
self.db.refresh(owner)
|
||||
return owner
|
||||
|
||||
def set_quiet_hours(
|
||||
self,
|
||||
principal: FeishuPrincipal,
|
||||
start: str,
|
||||
end: str,
|
||||
) -> FeishuUser:
|
||||
principal.require_active()
|
||||
try:
|
||||
quiet_start = parse_quiet_clock(start)
|
||||
quiet_end = parse_quiet_clock(end)
|
||||
except ScheduleParseError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
if quiet_start == quiet_end:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=INVALID_QUIET_HOURS,
|
||||
)
|
||||
owner = self._active_owner(principal, for_update=True)
|
||||
owner.quiet_hours_start = quiet_start
|
||||
owner.quiet_hours_end = quiet_end
|
||||
self._audit_user(
|
||||
principal,
|
||||
SubscriptionAuditAction.UPDATE_QUIET_HOURS,
|
||||
{"enabled": True},
|
||||
)
|
||||
self.db.commit()
|
||||
self.db.refresh(owner)
|
||||
return owner
|
||||
|
||||
def clear_quiet_hours(self, principal: FeishuPrincipal) -> FeishuUser:
|
||||
principal.require_active()
|
||||
owner = self._active_owner(principal, for_update=True)
|
||||
owner.quiet_hours_start = None
|
||||
owner.quiet_hours_end = None
|
||||
self._audit_user(
|
||||
principal,
|
||||
SubscriptionAuditAction.UPDATE_QUIET_HOURS,
|
||||
{"enabled": False},
|
||||
)
|
||||
self.db.commit()
|
||||
self.db.refresh(owner)
|
||||
return owner
|
||||
|
||||
def list_all(
|
||||
self,
|
||||
*,
|
||||
status_filter: str | None = None,
|
||||
owner_id: int | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> tuple[int, list[PushSubscription]]:
|
||||
stmt = select(PushSubscription)
|
||||
count_stmt = select(func.count()).select_from(PushSubscription)
|
||||
if status_filter:
|
||||
stmt = stmt.where(PushSubscription.status == status_filter)
|
||||
count_stmt = count_stmt.where(PushSubscription.status == status_filter)
|
||||
if owner_id is not None:
|
||||
stmt = stmt.where(PushSubscription.owner_id == owner_id)
|
||||
count_stmt = count_stmt.where(PushSubscription.owner_id == owner_id)
|
||||
stmt = (
|
||||
stmt.order_by(PushSubscription.id.desc())
|
||||
.limit(bounded_limit(limit))
|
||||
.offset(bounded_offset(offset))
|
||||
)
|
||||
total = int(self.db.scalar(count_stmt) or 0)
|
||||
return total, list(self.db.execute(stmt).scalars())
|
||||
|
||||
def list_deliveries(
|
||||
self,
|
||||
*,
|
||||
status_filter: str | None = None,
|
||||
subscription_code: str | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> tuple[int, list[PushDelivery]]:
|
||||
stmt = select(PushDelivery).join(PushSubscription)
|
||||
count_stmt = (
|
||||
select(func.count())
|
||||
.select_from(PushDelivery)
|
||||
.join(PushSubscription)
|
||||
)
|
||||
if status_filter:
|
||||
stmt = stmt.where(PushDelivery.status == status_filter)
|
||||
count_stmt = count_stmt.where(PushDelivery.status == status_filter)
|
||||
if subscription_code:
|
||||
stmt = stmt.where(PushSubscription.code == subscription_code)
|
||||
count_stmt = count_stmt.where(PushSubscription.code == subscription_code)
|
||||
stmt = (
|
||||
stmt.order_by(PushDelivery.id.desc())
|
||||
.limit(bounded_limit(limit))
|
||||
.offset(bounded_offset(offset))
|
||||
)
|
||||
total = int(self.db.scalar(count_stmt) or 0)
|
||||
return total, list(self.db.execute(stmt).scalars())
|
||||
|
||||
def _create(
|
||||
self,
|
||||
*,
|
||||
owner: FeishuUser,
|
||||
target_type: str,
|
||||
target_id: str,
|
||||
schedule_expression: str,
|
||||
prompt: str,
|
||||
now: datetime | None,
|
||||
) -> tuple[PushSubscription, NormalizedSchedule]:
|
||||
clean_prompt = str(prompt or "").strip()
|
||||
if not clean_prompt:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=EMPTY_PROMPT,
|
||||
)
|
||||
self._ensure_active_capacity(owner.id)
|
||||
try:
|
||||
schedule = parse_schedule(
|
||||
schedule_expression,
|
||||
owner.timezone,
|
||||
now=now,
|
||||
)
|
||||
except ScheduleParseError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
record = PushSubscription(
|
||||
code=f"SUB-{uuid4().hex.upper()}",
|
||||
owner_id=owner.id,
|
||||
target_type=target_type,
|
||||
target_id=target_id,
|
||||
prompt=clean_prompt,
|
||||
schedule_type=schedule.schedule_type,
|
||||
schedule_config=schedule.schedule_config,
|
||||
timezone=schedule.timezone,
|
||||
next_run_at=schedule.next_run_at,
|
||||
status=PushSubscriptionStatus.ACTIVE,
|
||||
consented_at=_naive_utc(now or utc_now()),
|
||||
)
|
||||
self.db.add(record)
|
||||
self.db.flush()
|
||||
self._audit_values(
|
||||
actor=owner.code,
|
||||
action=SubscriptionAuditAction.CREATE,
|
||||
target_id=record.code,
|
||||
response={
|
||||
"target_type": target_type,
|
||||
"schedule_type": schedule.schedule_type,
|
||||
},
|
||||
)
|
||||
self.db.commit()
|
||||
self.db.refresh(record)
|
||||
return record, schedule
|
||||
|
||||
def _ensure_active_capacity(self, owner_id: int) -> None:
|
||||
count = int(
|
||||
self.db.scalar(
|
||||
select(func.count())
|
||||
.select_from(PushSubscription)
|
||||
.where(
|
||||
PushSubscription.owner_id == owner_id,
|
||||
PushSubscription.status == PushSubscriptionStatus.ACTIVE,
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
if count >= MAX_ACTIVE_SUBSCRIPTIONS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=SUBSCRIPTION_LIMIT_REACHED,
|
||||
)
|
||||
|
||||
def _active_owner(
|
||||
self,
|
||||
principal: FeishuPrincipal,
|
||||
*,
|
||||
for_update: bool = False,
|
||||
) -> FeishuUser:
|
||||
stmt = select(FeishuUser).where(FeishuUser.id == principal.owner_id)
|
||||
if for_update:
|
||||
stmt = stmt.with_for_update()
|
||||
owner = self.db.execute(stmt).scalar_one_or_none()
|
||||
if owner is None or owner.status != FeishuUserStatus.ACTIVE:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Feishu user is disabled",
|
||||
)
|
||||
if owner.tenant_key != principal.tenant_key or owner.open_id != principal.open_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Feishu identity mismatch",
|
||||
)
|
||||
return owner
|
||||
|
||||
def _owned_subscription(
|
||||
self,
|
||||
owner_id: int,
|
||||
code: str,
|
||||
*,
|
||||
for_update: bool = False,
|
||||
) -> PushSubscription:
|
||||
stmt = select(PushSubscription).where(
|
||||
PushSubscription.owner_id == owner_id,
|
||||
PushSubscription.code == code,
|
||||
)
|
||||
if for_update:
|
||||
stmt = stmt.with_for_update()
|
||||
record = self.db.execute(stmt).scalar_one_or_none()
|
||||
if record is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=SUBSCRIPTION_NOT_FOUND,
|
||||
)
|
||||
return record
|
||||
|
||||
def _audit(
|
||||
self,
|
||||
principal: FeishuPrincipal,
|
||||
action: str,
|
||||
record: PushSubscription,
|
||||
) -> None:
|
||||
self._audit_values(
|
||||
actor=principal.user_code,
|
||||
action=action,
|
||||
target_id=record.code,
|
||||
response={"status": record.status},
|
||||
)
|
||||
|
||||
def _audit_user(
|
||||
self,
|
||||
principal: FeishuPrincipal,
|
||||
action: str,
|
||||
response: dict[str, Any],
|
||||
) -> None:
|
||||
self._audit_values(
|
||||
actor=principal.user_code,
|
||||
action=action,
|
||||
target_id=principal.user_code,
|
||||
response=response,
|
||||
)
|
||||
|
||||
def _audit_values(
|
||||
self,
|
||||
*,
|
||||
actor: str,
|
||||
action: str,
|
||||
target_id: str,
|
||||
response: dict[str, Any],
|
||||
) -> None:
|
||||
AuditService(self.db).record(
|
||||
AuditLogCreate(
|
||||
actor=actor,
|
||||
source="subscriptions",
|
||||
action=action,
|
||||
target_type="subscription",
|
||||
target_id=target_id,
|
||||
response_payload=response,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _naive_utc(value: datetime) -> datetime:
|
||||
if value.tzinfo is None:
|
||||
return value
|
||||
return value.astimezone(UTC).replace(tzinfo=None)
|
||||
309
app/modules/subscriptions/services/scanner.py
Normal file
309
app/modules/subscriptions/services/scanner.py
Normal file
@@ -0,0 +1,309 @@
|
||||
from datetime import UTC, datetime, time, timedelta
|
||||
from hashlib import sha256
|
||||
from uuid import NAMESPACE_URL, uuid4, uuid5
|
||||
|
||||
from sqlalchemy import func, or_, select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.http.pagination import bounded_limit
|
||||
from app.core.utils.time import utc_now
|
||||
from app.modules.audit.schemas import AuditLogCreate
|
||||
from app.modules.audit.service import AuditService
|
||||
from app.modules.feishu_users.constants import FeishuUserRole, FeishuUserStatus
|
||||
from app.modules.feishu_users.models import FeishuUser
|
||||
from app.modules.subscriptions.constants import (
|
||||
DAILY_DELIVERY_LIMIT_REACHED,
|
||||
MAX_DAILY_DELIVERIES,
|
||||
PushDeliveryStatus,
|
||||
PushSubscriptionStatus,
|
||||
SUBSCRIPTION_LEASE_SECONDS,
|
||||
SubscriptionAuditAction,
|
||||
SubscriptionScheduleType,
|
||||
SubscriptionTargetType,
|
||||
)
|
||||
from app.modules.subscriptions.models import PushDelivery, PushSubscription
|
||||
from app.modules.subscriptions.services.schedule import (
|
||||
is_in_quiet_hours,
|
||||
next_occurrence,
|
||||
next_quiet_end,
|
||||
validate_timezone,
|
||||
)
|
||||
|
||||
|
||||
class SubscriptionScanner:
|
||||
"""Claim due plans and materialize one durable delivery per schedule window."""
|
||||
|
||||
def __init__(self, db: Session, *, lease_seconds: int = SUBSCRIPTION_LEASE_SECONDS):
|
||||
self.db = db
|
||||
self.lease_seconds = lease_seconds
|
||||
|
||||
def scan_due(
|
||||
self,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
limit: int = 100,
|
||||
worker_id: str = "subscription-scanner",
|
||||
) -> list[PushDelivery]:
|
||||
current = _naive_utc(now or utc_now())
|
||||
claims = self._claim_due_subscriptions(
|
||||
current=current,
|
||||
limit=limit,
|
||||
worker_id=worker_id,
|
||||
)
|
||||
deliveries: list[PushDelivery] = []
|
||||
for subscription_id, lock_owner in claims:
|
||||
try:
|
||||
delivery = self._materialize_delivery(
|
||||
subscription_id=subscription_id,
|
||||
lock_owner=lock_owner,
|
||||
current=current,
|
||||
)
|
||||
except Exception:
|
||||
self.db.rollback()
|
||||
self._release_claim(subscription_id, lock_owner)
|
||||
raise
|
||||
if delivery is not None:
|
||||
deliveries.append(delivery)
|
||||
return deliveries
|
||||
|
||||
def _claim_due_subscriptions(
|
||||
self,
|
||||
*,
|
||||
current: datetime,
|
||||
limit: int,
|
||||
worker_id: str,
|
||||
) -> list[tuple[int, str]]:
|
||||
stmt = (
|
||||
select(PushSubscription.id)
|
||||
.where(
|
||||
PushSubscription.status == PushSubscriptionStatus.ACTIVE,
|
||||
PushSubscription.next_run_at.is_not(None),
|
||||
PushSubscription.next_run_at <= current,
|
||||
or_(
|
||||
PushSubscription.locked_until.is_(None),
|
||||
PushSubscription.locked_until <= current,
|
||||
),
|
||||
)
|
||||
.order_by(PushSubscription.next_run_at.asc(), PushSubscription.id.asc())
|
||||
.limit(bounded_limit(limit))
|
||||
)
|
||||
if self.db.get_bind().dialect.name == "postgresql":
|
||||
stmt = stmt.with_for_update(skip_locked=True)
|
||||
candidate_ids = list(self.db.execute(stmt).scalars())
|
||||
claims: list[tuple[int, str]] = []
|
||||
locked_until = current + timedelta(seconds=self.lease_seconds)
|
||||
for subscription_id in candidate_ids:
|
||||
lock_owner = f"{worker_id}:{uuid4().hex}"
|
||||
result = self.db.execute(
|
||||
update(PushSubscription)
|
||||
.where(
|
||||
PushSubscription.id == subscription_id,
|
||||
PushSubscription.status == PushSubscriptionStatus.ACTIVE,
|
||||
PushSubscription.next_run_at.is_not(None),
|
||||
PushSubscription.next_run_at <= current,
|
||||
or_(
|
||||
PushSubscription.locked_until.is_(None),
|
||||
PushSubscription.locked_until <= current,
|
||||
),
|
||||
)
|
||||
.values(locked_by=lock_owner, locked_until=locked_until)
|
||||
.execution_options(synchronize_session=False)
|
||||
)
|
||||
if result.rowcount == 1:
|
||||
claims.append((subscription_id, lock_owner))
|
||||
self.db.commit()
|
||||
return claims
|
||||
|
||||
def _materialize_delivery(
|
||||
self,
|
||||
*,
|
||||
subscription_id: int,
|
||||
lock_owner: str,
|
||||
current: datetime,
|
||||
) -> PushDelivery | None:
|
||||
subscription = self.db.execute(
|
||||
select(PushSubscription)
|
||||
.where(
|
||||
PushSubscription.id == subscription_id,
|
||||
PushSubscription.locked_by == lock_owner,
|
||||
)
|
||||
.with_for_update()
|
||||
).scalar_one_or_none()
|
||||
if subscription is None:
|
||||
self.db.rollback()
|
||||
return None
|
||||
if (
|
||||
subscription.status != PushSubscriptionStatus.ACTIVE
|
||||
or subscription.next_run_at is None
|
||||
):
|
||||
subscription.locked_by = None
|
||||
subscription.locked_until = None
|
||||
self.db.commit()
|
||||
return None
|
||||
|
||||
owner = self.db.execute(
|
||||
select(FeishuUser)
|
||||
.where(FeishuUser.id == subscription.owner_id)
|
||||
.with_for_update()
|
||||
).scalar_one_or_none()
|
||||
scheduled_for = subscription.next_run_at
|
||||
skip_reason = self._delivery_skip_reason(subscription, owner, current)
|
||||
next_attempt_at = current
|
||||
if (
|
||||
skip_reason is None
|
||||
and owner is not None
|
||||
and is_in_quiet_hours(
|
||||
current,
|
||||
owner.timezone,
|
||||
owner.quiet_hours_start,
|
||||
owner.quiet_hours_end,
|
||||
)
|
||||
):
|
||||
next_attempt_at = next_quiet_end(
|
||||
current,
|
||||
owner.timezone,
|
||||
owner.quiet_hours_start,
|
||||
owner.quiet_hours_end,
|
||||
)
|
||||
|
||||
idempotency_key = _delivery_key(subscription.id, scheduled_for)
|
||||
message_uuid = str(uuid5(NAMESPACE_URL, f"company-ai-platform:{idempotency_key}"))
|
||||
delivery = PushDelivery(
|
||||
code=f"DEL-{uuid4().hex.upper()}",
|
||||
subscription_id=subscription.id,
|
||||
scheduled_for=scheduled_for,
|
||||
idempotency_key=idempotency_key,
|
||||
message_uuid=message_uuid,
|
||||
status=(
|
||||
PushDeliveryStatus.SKIPPED
|
||||
if skip_reason is not None
|
||||
else PushDeliveryStatus.PENDING
|
||||
),
|
||||
next_attempt_at=None if skip_reason is not None else next_attempt_at,
|
||||
last_error=skip_reason,
|
||||
created_at=current,
|
||||
updated_at=current,
|
||||
)
|
||||
try:
|
||||
with self.db.begin_nested():
|
||||
self.db.add(delivery)
|
||||
self.db.flush()
|
||||
except IntegrityError:
|
||||
delivery = self.db.execute(
|
||||
select(PushDelivery).where(
|
||||
PushDelivery.idempotency_key == idempotency_key
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
subscription.last_run_at = scheduled_for
|
||||
if subscription.schedule_type == SubscriptionScheduleType.ONCE:
|
||||
subscription.status = PushSubscriptionStatus.COMPLETED
|
||||
subscription.next_run_at = None
|
||||
else:
|
||||
subscription.next_run_at = next_occurrence(
|
||||
subscription.schedule_type,
|
||||
subscription.schedule_config,
|
||||
subscription.timezone,
|
||||
after=current,
|
||||
)
|
||||
subscription.locked_by = None
|
||||
subscription.locked_until = None
|
||||
if skip_reason is not None:
|
||||
self._audit_skipped(owner, delivery, skip_reason)
|
||||
self.db.commit()
|
||||
self.db.refresh(delivery)
|
||||
return delivery
|
||||
|
||||
def _delivery_skip_reason(
|
||||
self,
|
||||
subscription: PushSubscription,
|
||||
owner: FeishuUser | None,
|
||||
current: datetime,
|
||||
) -> str | None:
|
||||
if owner is None or owner.status != FeishuUserStatus.ACTIVE:
|
||||
return "Feishu user is disabled"
|
||||
if (
|
||||
subscription.target_type == SubscriptionTargetType.USER
|
||||
and subscription.target_id != owner.open_id
|
||||
):
|
||||
return "Private subscription target no longer matches its owner"
|
||||
if subscription.target_type == SubscriptionTargetType.CHAT and (
|
||||
owner.role != FeishuUserRole.ADMIN or not subscription.target_id
|
||||
):
|
||||
return "Group subscription owner is no longer an administrator"
|
||||
if subscription.target_type not in {
|
||||
SubscriptionTargetType.USER,
|
||||
SubscriptionTargetType.CHAT,
|
||||
}:
|
||||
return "Unsupported subscription target"
|
||||
if self._daily_delivery_count(owner, current) >= MAX_DAILY_DELIVERIES:
|
||||
return DAILY_DELIVERY_LIMIT_REACHED
|
||||
return None
|
||||
|
||||
def _daily_delivery_count(self, owner: FeishuUser, current: datetime) -> int:
|
||||
zone = validate_timezone(owner.timezone)
|
||||
local_now = current.replace(tzinfo=UTC).astimezone(zone)
|
||||
local_start = datetime.combine(local_now.date(), time.min, tzinfo=zone)
|
||||
local_end = local_start + timedelta(days=1)
|
||||
start_utc = local_start.astimezone(UTC).replace(tzinfo=None)
|
||||
end_utc = local_end.astimezone(UTC).replace(tzinfo=None)
|
||||
return int(
|
||||
self.db.scalar(
|
||||
select(func.count())
|
||||
.select_from(PushDelivery)
|
||||
.join(PushSubscription)
|
||||
.where(
|
||||
PushSubscription.owner_id == owner.id,
|
||||
PushDelivery.created_at >= start_utc,
|
||||
PushDelivery.created_at < end_utc,
|
||||
PushDelivery.status.not_in(
|
||||
[
|
||||
PushDeliveryStatus.FAILED,
|
||||
PushDeliveryStatus.SKIPPED,
|
||||
]
|
||||
),
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
def _audit_skipped(
|
||||
self,
|
||||
owner: FeishuUser | None,
|
||||
delivery: PushDelivery,
|
||||
reason: str,
|
||||
) -> None:
|
||||
AuditService(self.db).record(
|
||||
AuditLogCreate(
|
||||
actor=owner.code if owner is not None else "subscription-system",
|
||||
source="subscriptions",
|
||||
action=SubscriptionAuditAction.DELIVERY_SKIPPED,
|
||||
target_type="push-delivery",
|
||||
target_id=delivery.code,
|
||||
response_payload={"status": PushDeliveryStatus.SKIPPED, "reason": reason},
|
||||
)
|
||||
)
|
||||
|
||||
def _release_claim(self, subscription_id: int, lock_owner: str) -> None:
|
||||
self.db.execute(
|
||||
update(PushSubscription)
|
||||
.where(
|
||||
PushSubscription.id == subscription_id,
|
||||
PushSubscription.locked_by == lock_owner,
|
||||
)
|
||||
.values(locked_by=None, locked_until=None)
|
||||
.execution_options(synchronize_session=False)
|
||||
)
|
||||
self.db.commit()
|
||||
|
||||
|
||||
def _delivery_key(subscription_id: int, scheduled_for: datetime) -> str:
|
||||
material = f"{subscription_id}:{_naive_utc(scheduled_for).isoformat(timespec='microseconds')}"
|
||||
return sha256(material.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _naive_utc(value: datetime) -> datetime:
|
||||
if value.tzinfo is None:
|
||||
return value
|
||||
return value.astimezone(UTC).replace(tzinfo=None)
|
||||
462
app/modules/subscriptions/services/schedule.py
Normal file
462
app/modules/subscriptions/services/schedule.py
Normal file
@@ -0,0 +1,462 @@
|
||||
import re
|
||||
from calendar import monthrange
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, date, datetime, time, timedelta
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from app.modules.subscriptions.constants import (
|
||||
INVALID_SCHEDULE,
|
||||
INVALID_TIMEZONE,
|
||||
MIN_INTERVAL_MINUTES,
|
||||
SubscriptionScheduleType,
|
||||
)
|
||||
|
||||
_WEEKDAYS = {
|
||||
"一": 0,
|
||||
"二": 1,
|
||||
"三": 2,
|
||||
"四": 3,
|
||||
"五": 4,
|
||||
"六": 5,
|
||||
"日": 6,
|
||||
"天": 6,
|
||||
}
|
||||
_WEEKDAY_NAMES = ("一", "二", "三", "四", "五", "六", "日")
|
||||
_INTERVAL_PATTERN = re.compile(r"每隔\s*(?P<value>\d+)\s*(?P<unit>分钟|小时)")
|
||||
_DAILY_PATTERN = re.compile(r"每天\s*(?P<clock>.+)")
|
||||
_WEEKDAY_PATTERN = re.compile(r"(?:每个)?工作日\s*(?P<clock>.+)")
|
||||
_WEEKLY_PATTERN = re.compile(r"每周(?P<weekday>[一二三四五六日天])\s*(?P<clock>.+)")
|
||||
_MONTHLY_PATTERN = re.compile(
|
||||
r"每月\s*(?P<day>\d{1,2})\s*(?:号|日)\s*(?P<clock>.+)"
|
||||
)
|
||||
_RELATIVE_PATTERN = re.compile(r"(?P<day>今天|明天)\s*(?P<clock>.+)")
|
||||
_ISO_DATE_PATTERN = re.compile(
|
||||
r"(?P<year>\d{4})[-/](?P<month>\d{1,2})[-/](?P<day>\d{1,2})"
|
||||
r"\s+(?P<clock>.+)"
|
||||
)
|
||||
_CHINESE_DATE_PATTERN = re.compile(
|
||||
r"(?P<year>\d{4})年(?P<month>\d{1,2})月(?P<day>\d{1,2})[日号]"
|
||||
r"\s*(?P<clock>.+)"
|
||||
)
|
||||
_COLON_CLOCK_PATTERN = re.compile(r"(?P<hour>\d{1,2}):(?P<minute>\d{1,2})")
|
||||
_CHINESE_CLOCK_PATTERN = re.compile(
|
||||
r"(?P<hour>\d{1,2})点(?:(?P<half>半)|(?P<minute>\d{1,2})分?)?"
|
||||
)
|
||||
|
||||
|
||||
class ScheduleParseError(ValueError):
|
||||
"""Raised when a controlled schedule expression cannot be normalized."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class NormalizedSchedule:
|
||||
schedule_type: str
|
||||
schedule_config: dict[str, Any]
|
||||
timezone: str
|
||||
next_run_at: datetime
|
||||
display: str
|
||||
|
||||
|
||||
def parse_schedule(
|
||||
expression: str,
|
||||
timezone_name: str,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
) -> NormalizedSchedule:
|
||||
"""Parse the supported Chinese schedule grammar into a UTC plan."""
|
||||
|
||||
text = _normalize_expression(expression)
|
||||
zone = validate_timezone(timezone_name)
|
||||
now_utc = _as_utc(now)
|
||||
local_now = now_utc.astimezone(zone)
|
||||
|
||||
match = _INTERVAL_PATTERN.fullmatch(text)
|
||||
if match:
|
||||
value = int(match.group("value"))
|
||||
minutes = value * (60 if match.group("unit") == "小时" else 1)
|
||||
if minutes < MIN_INTERVAL_MINUTES:
|
||||
raise ScheduleParseError(f"订阅间隔不得短于 {MIN_INTERVAL_MINUTES} 分钟")
|
||||
try:
|
||||
next_run = now_utc + timedelta(minutes=minutes)
|
||||
except OverflowError as exc:
|
||||
raise ScheduleParseError("订阅间隔过大") from exc
|
||||
config = {
|
||||
"minutes": minutes,
|
||||
"anchor_at": _to_naive_utc(next_run).isoformat(),
|
||||
}
|
||||
display_value = (
|
||||
f"每隔 {value} 小时" if match.group("unit") == "小时" else f"每隔 {value} 分钟"
|
||||
)
|
||||
return NormalizedSchedule(
|
||||
schedule_type=SubscriptionScheduleType.INTERVAL,
|
||||
schedule_config=config,
|
||||
timezone=timezone_name,
|
||||
next_run_at=_to_naive_utc(next_run),
|
||||
display=display_value,
|
||||
)
|
||||
|
||||
match = _DAILY_PATTERN.fullmatch(text)
|
||||
if match:
|
||||
hour, minute = _parse_clock(match.group("clock"))
|
||||
config = {"hour": hour, "minute": minute}
|
||||
return _recurring_schedule(
|
||||
SubscriptionScheduleType.DAILY,
|
||||
config,
|
||||
timezone_name,
|
||||
now_utc,
|
||||
f"每天 {hour:02d}:{minute:02d}",
|
||||
)
|
||||
|
||||
match = _WEEKDAY_PATTERN.fullmatch(text)
|
||||
if match:
|
||||
hour, minute = _parse_clock(match.group("clock"))
|
||||
config = {"hour": hour, "minute": minute}
|
||||
return _recurring_schedule(
|
||||
SubscriptionScheduleType.WEEKDAY,
|
||||
config,
|
||||
timezone_name,
|
||||
now_utc,
|
||||
f"工作日 {hour:02d}:{minute:02d}",
|
||||
)
|
||||
|
||||
match = _WEEKLY_PATTERN.fullmatch(text)
|
||||
if match:
|
||||
hour, minute = _parse_clock(match.group("clock"))
|
||||
weekday = _WEEKDAYS[match.group("weekday")]
|
||||
config = {"weekday": weekday, "hour": hour, "minute": minute}
|
||||
return _recurring_schedule(
|
||||
SubscriptionScheduleType.WEEKLY,
|
||||
config,
|
||||
timezone_name,
|
||||
now_utc,
|
||||
f"每周{_WEEKDAY_NAMES[weekday]} {hour:02d}:{minute:02d}",
|
||||
)
|
||||
|
||||
match = _MONTHLY_PATTERN.fullmatch(text)
|
||||
if match:
|
||||
day = int(match.group("day"))
|
||||
if not 1 <= day <= 31:
|
||||
raise ScheduleParseError("每月日期必须在 1 到 31 之间")
|
||||
hour, minute = _parse_clock(match.group("clock"))
|
||||
config = {"day": day, "hour": hour, "minute": minute}
|
||||
return _recurring_schedule(
|
||||
SubscriptionScheduleType.MONTHLY,
|
||||
config,
|
||||
timezone_name,
|
||||
now_utc,
|
||||
f"每月 {day} 号 {hour:02d}:{minute:02d}",
|
||||
)
|
||||
|
||||
match = _RELATIVE_PATTERN.fullmatch(text)
|
||||
if match:
|
||||
hour, minute = _parse_clock(match.group("clock"))
|
||||
offset = 1 if match.group("day") == "明天" else 0
|
||||
target_date = local_now.date() + timedelta(days=offset)
|
||||
return _once_schedule(
|
||||
target_date,
|
||||
hour,
|
||||
minute,
|
||||
timezone_name,
|
||||
now_utc,
|
||||
f"{match.group('day')} {hour:02d}:{minute:02d}",
|
||||
)
|
||||
|
||||
match = _ISO_DATE_PATTERN.fullmatch(text) or _CHINESE_DATE_PATTERN.fullmatch(text)
|
||||
if match:
|
||||
try:
|
||||
target_date = date(
|
||||
int(match.group("year")),
|
||||
int(match.group("month")),
|
||||
int(match.group("day")),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise ScheduleParseError("日期不存在") from exc
|
||||
hour, minute = _parse_clock(match.group("clock"))
|
||||
return _once_schedule(
|
||||
target_date,
|
||||
hour,
|
||||
minute,
|
||||
timezone_name,
|
||||
now_utc,
|
||||
f"{target_date.isoformat()} {hour:02d}:{minute:02d}",
|
||||
)
|
||||
|
||||
raise ScheduleParseError(
|
||||
f"{INVALID_SCHEDULE}。示例:每天 09:00、每周一 18:00、每隔 30 分钟"
|
||||
)
|
||||
|
||||
|
||||
def next_occurrence(
|
||||
schedule_type: str,
|
||||
schedule_config: dict[str, Any],
|
||||
timezone_name: str,
|
||||
*,
|
||||
after: datetime,
|
||||
) -> datetime | None:
|
||||
"""Return the first UTC occurrence strictly after ``after``."""
|
||||
|
||||
zone = validate_timezone(timezone_name)
|
||||
after_utc = _as_utc(after)
|
||||
plan_type = SubscriptionScheduleType(schedule_type)
|
||||
|
||||
if plan_type == SubscriptionScheduleType.ONCE:
|
||||
run_at = _parse_stored_utc(schedule_config["run_at"])
|
||||
return _to_naive_utc(run_at) if run_at > after_utc else None
|
||||
|
||||
if plan_type == SubscriptionScheduleType.INTERVAL:
|
||||
interval = timedelta(minutes=int(schedule_config["minutes"]))
|
||||
anchor = _parse_stored_utc(schedule_config["anchor_at"])
|
||||
if anchor > after_utc:
|
||||
return _to_naive_utc(anchor)
|
||||
elapsed = after_utc - anchor
|
||||
steps = elapsed // interval + 1
|
||||
return _to_naive_utc(anchor + interval * steps)
|
||||
|
||||
hour = int(schedule_config["hour"])
|
||||
minute = int(schedule_config["minute"])
|
||||
local_after = after_utc.astimezone(zone)
|
||||
|
||||
if plan_type == SubscriptionScheduleType.DAILY:
|
||||
return _next_daily(local_after, hour, minute, zone)
|
||||
if plan_type == SubscriptionScheduleType.WEEKDAY:
|
||||
return _next_weekday(local_after, hour, minute, zone)
|
||||
if plan_type == SubscriptionScheduleType.WEEKLY:
|
||||
weekday = int(schedule_config["weekday"])
|
||||
return _next_weekly(local_after, weekday, hour, minute, zone)
|
||||
if plan_type == SubscriptionScheduleType.MONTHLY:
|
||||
day = int(schedule_config["day"])
|
||||
return _next_monthly(local_after, day, hour, minute, zone)
|
||||
raise ScheduleParseError(INVALID_SCHEDULE)
|
||||
|
||||
|
||||
def is_in_quiet_hours(
|
||||
current: datetime,
|
||||
timezone_name: str,
|
||||
quiet_start: time | str | None,
|
||||
quiet_end: time | str | None,
|
||||
) -> bool:
|
||||
if quiet_start is None or quiet_end is None:
|
||||
return False
|
||||
start = _coerce_time(quiet_start)
|
||||
end = _coerce_time(quiet_end)
|
||||
if start == end:
|
||||
return False
|
||||
local_time = _as_utc(current).astimezone(validate_timezone(timezone_name)).time()
|
||||
local_time = local_time.replace(tzinfo=None)
|
||||
if start < end:
|
||||
return start <= local_time < end
|
||||
return local_time >= start or local_time < end
|
||||
|
||||
|
||||
def next_quiet_end(
|
||||
current: datetime,
|
||||
timezone_name: str,
|
||||
quiet_start: time | str,
|
||||
quiet_end: time | str,
|
||||
) -> datetime:
|
||||
"""Return quiet-window end as a naive UTC timestamp."""
|
||||
|
||||
zone = validate_timezone(timezone_name)
|
||||
now_local = _as_utc(current).astimezone(zone)
|
||||
start = _coerce_time(quiet_start)
|
||||
end = _coerce_time(quiet_end)
|
||||
end_date = now_local.date()
|
||||
if start > end and now_local.time().replace(tzinfo=None) >= start:
|
||||
end_date += timedelta(days=1)
|
||||
candidate = _local_candidate(end_date, end.hour, end.minute, zone)
|
||||
if candidate is None:
|
||||
candidate = _first_valid_local_after(end_date, end.hour, end.minute, zone)
|
||||
return _to_naive_utc(candidate)
|
||||
|
||||
|
||||
def validate_timezone(timezone_name: str) -> ZoneInfo:
|
||||
try:
|
||||
return ZoneInfo(timezone_name)
|
||||
except (ZoneInfoNotFoundError, ValueError, TypeError) as exc:
|
||||
raise ScheduleParseError(INVALID_TIMEZONE) from exc
|
||||
|
||||
|
||||
def parse_quiet_clock(value: str) -> time:
|
||||
hour, minute = _parse_clock(_normalize_expression(value))
|
||||
return time(hour=hour, minute=minute)
|
||||
|
||||
|
||||
def _recurring_schedule(
|
||||
schedule_type: str,
|
||||
config: dict[str, Any],
|
||||
timezone_name: str,
|
||||
now_utc: datetime,
|
||||
display: str,
|
||||
) -> NormalizedSchedule:
|
||||
next_run = next_occurrence(
|
||||
schedule_type,
|
||||
config,
|
||||
timezone_name,
|
||||
after=now_utc,
|
||||
)
|
||||
if next_run is None:
|
||||
raise ScheduleParseError(INVALID_SCHEDULE)
|
||||
return NormalizedSchedule(
|
||||
schedule_type=schedule_type,
|
||||
schedule_config=config,
|
||||
timezone=timezone_name,
|
||||
next_run_at=next_run,
|
||||
display=display,
|
||||
)
|
||||
|
||||
|
||||
def _once_schedule(
|
||||
target_date: date,
|
||||
hour: int,
|
||||
minute: int,
|
||||
timezone_name: str,
|
||||
now_utc: datetime,
|
||||
display: str,
|
||||
) -> NormalizedSchedule:
|
||||
zone = validate_timezone(timezone_name)
|
||||
target = _local_candidate(target_date, hour, minute, zone)
|
||||
if target is None:
|
||||
raise ScheduleParseError("该本地时间不存在")
|
||||
if target <= now_utc:
|
||||
raise ScheduleParseError("执行时间必须晚于当前时间")
|
||||
run_at = _to_naive_utc(target)
|
||||
return NormalizedSchedule(
|
||||
schedule_type=SubscriptionScheduleType.ONCE,
|
||||
schedule_config={"run_at": run_at.isoformat()},
|
||||
timezone=timezone_name,
|
||||
next_run_at=run_at,
|
||||
display=display,
|
||||
)
|
||||
|
||||
|
||||
def _next_daily(local_after: datetime, hour: int, minute: int, zone: ZoneInfo) -> datetime:
|
||||
for offset in range(0, 370):
|
||||
candidate = _local_candidate(local_after.date() + timedelta(days=offset), hour, minute, zone)
|
||||
if candidate is not None and candidate > local_after.astimezone(UTC):
|
||||
return _to_naive_utc(candidate)
|
||||
raise ScheduleParseError(INVALID_SCHEDULE)
|
||||
|
||||
|
||||
def _next_weekday(local_after: datetime, hour: int, minute: int, zone: ZoneInfo) -> datetime:
|
||||
for offset in range(0, 14):
|
||||
target_date = local_after.date() + timedelta(days=offset)
|
||||
if target_date.weekday() >= 5:
|
||||
continue
|
||||
candidate = _local_candidate(target_date, hour, minute, zone)
|
||||
if candidate is not None and candidate > local_after.astimezone(UTC):
|
||||
return _to_naive_utc(candidate)
|
||||
raise ScheduleParseError(INVALID_SCHEDULE)
|
||||
|
||||
|
||||
def _next_weekly(
|
||||
local_after: datetime,
|
||||
weekday: int,
|
||||
hour: int,
|
||||
minute: int,
|
||||
zone: ZoneInfo,
|
||||
) -> datetime:
|
||||
offset = (weekday - local_after.weekday()) % 7
|
||||
for weeks in range(0, 3):
|
||||
target_date = local_after.date() + timedelta(days=offset + weeks * 7)
|
||||
candidate = _local_candidate(target_date, hour, minute, zone)
|
||||
if candidate is not None and candidate > local_after.astimezone(UTC):
|
||||
return _to_naive_utc(candidate)
|
||||
raise ScheduleParseError(INVALID_SCHEDULE)
|
||||
|
||||
|
||||
def _next_monthly(
|
||||
local_after: datetime,
|
||||
day: int,
|
||||
hour: int,
|
||||
minute: int,
|
||||
zone: ZoneInfo,
|
||||
) -> datetime:
|
||||
year = local_after.year
|
||||
month = local_after.month
|
||||
for _ in range(0, 240):
|
||||
if day <= monthrange(year, month)[1]:
|
||||
candidate = _local_candidate(date(year, month, day), hour, minute, zone)
|
||||
if candidate is not None and candidate > local_after.astimezone(UTC):
|
||||
return _to_naive_utc(candidate)
|
||||
month += 1
|
||||
if month == 13:
|
||||
year += 1
|
||||
month = 1
|
||||
raise ScheduleParseError(INVALID_SCHEDULE)
|
||||
|
||||
|
||||
def _normalize_expression(expression: str) -> str:
|
||||
text = re.sub(r"\s+", " ", str(expression or "").strip()).replace(":", ":")
|
||||
if not text:
|
||||
raise ScheduleParseError(INVALID_SCHEDULE)
|
||||
return text
|
||||
|
||||
|
||||
def _parse_clock(value: str) -> tuple[int, int]:
|
||||
text = value.strip().replace(":", ":")
|
||||
match = _COLON_CLOCK_PATTERN.fullmatch(text)
|
||||
if match:
|
||||
hour = int(match.group("hour"))
|
||||
minute = int(match.group("minute"))
|
||||
else:
|
||||
match = _CHINESE_CLOCK_PATTERN.fullmatch(text)
|
||||
if not match:
|
||||
raise ScheduleParseError("时间必须使用 HH:MM 或 H点M分")
|
||||
hour = int(match.group("hour"))
|
||||
minute = 30 if match.group("half") else int(match.group("minute") or 0)
|
||||
if not 0 <= hour <= 23 or not 0 <= minute <= 59:
|
||||
raise ScheduleParseError("时间超出有效范围")
|
||||
return hour, minute
|
||||
|
||||
|
||||
def _local_candidate(
|
||||
target_date: date,
|
||||
hour: int,
|
||||
minute: int,
|
||||
zone: ZoneInfo,
|
||||
) -> datetime | None:
|
||||
naive = datetime.combine(target_date, time(hour=hour, minute=minute))
|
||||
aware = naive.replace(tzinfo=zone)
|
||||
roundtrip = aware.astimezone(UTC).astimezone(zone).replace(tzinfo=None)
|
||||
if roundtrip != naive:
|
||||
return None
|
||||
return aware.astimezone(UTC)
|
||||
|
||||
|
||||
def _first_valid_local_after(
|
||||
target_date: date,
|
||||
hour: int,
|
||||
minute: int,
|
||||
zone: ZoneInfo,
|
||||
) -> datetime:
|
||||
base = datetime.combine(target_date, time(hour=hour, minute=minute))
|
||||
for offset in range(0, 181):
|
||||
candidate = base + timedelta(minutes=offset)
|
||||
aware = _local_candidate(candidate.date(), candidate.hour, candidate.minute, zone)
|
||||
if aware is not None:
|
||||
return aware
|
||||
raise ScheduleParseError("安静时段结束时间无效")
|
||||
|
||||
|
||||
def _coerce_time(value: time | str) -> time:
|
||||
if isinstance(value, time):
|
||||
return value.replace(tzinfo=None, second=0, microsecond=0)
|
||||
return parse_quiet_clock(value)
|
||||
|
||||
|
||||
def _as_utc(value: datetime | None) -> datetime:
|
||||
if value is None:
|
||||
return datetime.now(UTC)
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=UTC)
|
||||
return value.astimezone(UTC)
|
||||
|
||||
|
||||
def _to_naive_utc(value: datetime) -> datetime:
|
||||
return _as_utc(value).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _parse_stored_utc(value: str | datetime) -> datetime:
|
||||
parsed = value if isinstance(value, datetime) else datetime.fromisoformat(str(value))
|
||||
return _as_utc(parsed)
|
||||
@@ -47,6 +47,16 @@ class WorkflowAction(Base):
|
||||
ForeignKey("workflow_instances.code", ondelete="RESTRICT"),
|
||||
index=True,
|
||||
)
|
||||
source_event_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey(
|
||||
"domain_events.event_id",
|
||||
name="fk_workflow_actions_source_event_id",
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
nullable=True,
|
||||
unique=True,
|
||||
index=True,
|
||||
)
|
||||
action: Mapped[str] = mapped_column(String(128), index=True)
|
||||
actor: Mapped[str] = mapped_column(String(128), default=ActorValue.SYSTEM, index=True)
|
||||
from_status: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
|
||||
@@ -3,6 +3,7 @@ from uuid import uuid4
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.constants import ActorValue
|
||||
@@ -34,20 +35,30 @@ class WorkflowService:
|
||||
actor: str = ActorValue.SYSTEM,
|
||||
payload: dict[str, Any] | None = None,
|
||||
commit: bool = True,
|
||||
source_event_id: str | None = None,
|
||||
) -> WorkflowInstance:
|
||||
existing_workflow = self._workflow_for_source_event(source_event_id)
|
||||
if existing_workflow is not None:
|
||||
return existing_workflow
|
||||
|
||||
aggregate_id_text = str(aggregate_id) if aggregate_id is not None else None
|
||||
record = self.db.execute(
|
||||
workflow_query = (
|
||||
select(WorkflowInstance).where(
|
||||
WorkflowInstance.workflow_type == workflow_type,
|
||||
WorkflowInstance.aggregate_type == aggregate_type,
|
||||
WorkflowInstance.aggregate_id == aggregate_id_text,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
.with_for_update()
|
||||
)
|
||||
record = self.db.execute(workflow_query).scalar_one_or_none()
|
||||
previous_status = None
|
||||
now = utc_now()
|
||||
if record is None:
|
||||
record = WorkflowInstance(
|
||||
code=f"{WORKFLOW_CODE_PREFIX}-{now:%Y%m%d%H%M%S%f}",
|
||||
candidate = WorkflowInstance(
|
||||
code=(
|
||||
f"{WORKFLOW_CODE_PREFIX}-{now:%Y%m%d%H%M%S%f}-"
|
||||
f"{uuid4().hex[:8]}"
|
||||
),
|
||||
workflow_type=workflow_type,
|
||||
aggregate_type=aggregate_type,
|
||||
aggregate_id=aggregate_id_text,
|
||||
@@ -56,10 +67,26 @@ class WorkflowService:
|
||||
current_step=action,
|
||||
payload=payload or {},
|
||||
)
|
||||
self.db.add(record)
|
||||
self.db.flush()
|
||||
try:
|
||||
with self.db.begin_nested():
|
||||
self.db.add(candidate)
|
||||
self.db.flush()
|
||||
record = candidate
|
||||
except IntegrityError:
|
||||
record = self.db.execute(workflow_query).scalar_one()
|
||||
previous_status = record.status
|
||||
else:
|
||||
previous_status = record.status
|
||||
|
||||
existing_workflow = self._workflow_for_source_event(source_event_id)
|
||||
if existing_workflow is not None:
|
||||
if existing_workflow.code != record.code:
|
||||
raise ValueError(
|
||||
"Source event is already attached to a different workflow"
|
||||
)
|
||||
return existing_workflow
|
||||
|
||||
if previous_status is not None:
|
||||
record.status = status_value
|
||||
record.actor = actor
|
||||
record.current_step = action
|
||||
@@ -86,6 +113,7 @@ class WorkflowService:
|
||||
f"{uuid4().hex[:8]}"
|
||||
),
|
||||
workflow_code=record.code,
|
||||
source_event_id=source_event_id,
|
||||
action=action,
|
||||
actor=actor,
|
||||
from_status=previous_status,
|
||||
@@ -100,6 +128,23 @@ class WorkflowService:
|
||||
self.db.flush()
|
||||
return record
|
||||
|
||||
def _workflow_for_source_event(
|
||||
self,
|
||||
source_event_id: str | None,
|
||||
) -> WorkflowInstance | None:
|
||||
if not source_event_id:
|
||||
return None
|
||||
action = self.db.execute(
|
||||
select(WorkflowAction).where(
|
||||
WorkflowAction.source_event_id == source_event_id
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if action is None:
|
||||
return None
|
||||
return self.db.execute(
|
||||
select(WorkflowInstance).where(WorkflowInstance.code == action.workflow_code)
|
||||
).scalar_one()
|
||||
|
||||
def list_workflows(
|
||||
self,
|
||||
status_filter: str | None = None,
|
||||
|
||||
Reference in New Issue
Block a user