feat: 添加飞书用户模块和订阅功能支持

- 新增feishu_users模块用于处理飞书用户身份验证和权限管理
- 新增subscriptions模块用于处理订阅相关功能
- 新增personalization模块用于个性化服务
- 在alembic迁移配置中注册新的模型模块
- 在API路由器中添加feishu_users和subscriptions路由
- 实现事件调度服务的改进,包括错误处理和状态更新优化
- 添加飞书命令处理的权限检查机制
- 实现飞书应用票据事件处理
- 改进审计日志记录功能
```
This commit is contained in:
2026-07-27 08:02:17 +08:00
parent db751f03b4
commit d7db84571d
148 changed files with 17110 additions and 765 deletions

View File

@@ -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("/")

View File

@@ -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:

View File

@@ -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],

View File

@@ -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"

View File

@@ -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,

View File

@@ -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):

View File

@@ -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",
)

View File

@@ -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):

View File

@@ -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)

View File

@@ -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}}

View File

@@ -14,6 +14,8 @@ class AIMemoryRecallRequest(BaseModel):
class AIMemoryRead(BaseModel):
code: str
owner_id: int | None
kind: str
scope: str
subject: str
content: str

View File

@@ -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:

View File

@@ -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",
}
)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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:

View File

@@ -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(

View 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

View File

@@ -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)

View File

@@ -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"

View 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 {}

View 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",
)

View File

@@ -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(

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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

View 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",
]

View 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()

View 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)

View 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)

View 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,
)

View 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,
)

View 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

View 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",
]

View 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

View 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,
)

View File

@@ -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"

View File

@@ -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,

View File

@@ -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",
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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)

View File

@@ -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(

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View 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",
]

View 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",
"密码",
"密钥",
"令牌",
"健康",
"病史",
"疾病",
"诊断",
"医疗记录",
"宗教",
"信仰",
"政治立场",
"党派",
"选举倾向",
"性取向",
"同性恋",
"异性恋",
"绩效",
"考核结果",
"银行账号",
"银行卡",
"工资",
"薪资",
"个人收入",
"财务秘密",
"未公开财务",
"保密预算",
)

View 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,
)

View 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)

View 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",
]

View 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
]

View 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(),
}

View 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

View 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(),
}

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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,

View File

@@ -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)

View File

@@ -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,

View File

@@ -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

View 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",
]

View 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"

View 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")

View 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],
}

View 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

View 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",
]

View 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)

View 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)

View 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)

View 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)

View File

@@ -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)

View File

@@ -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,