fix:更新定时任务push
This commit is contained in:
@@ -46,7 +46,7 @@ function getApiBaseUrl(env: AppRuntimeEnv): string {
|
|||||||
return getOptionalEnv('EXPO_PUBLIC_API_BASE_URL_PROD', getOptionalEnv('EXPO_PUBLIC_API_BASE_URL_LOCAL', 'https://api.damer.fun'));
|
return getOptionalEnv('EXPO_PUBLIC_API_BASE_URL_PROD', getOptionalEnv('EXPO_PUBLIC_API_BASE_URL_LOCAL', 'https://api.damer.fun'));
|
||||||
}
|
}
|
||||||
|
|
||||||
export const API_BASE_URL = getApiBaseUrl(APP_ENV);
|
export const API_BASE_URL = getApiBaseUrl(APPpai qa
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 调试:打印环境变量注入结果(仅开发环境)
|
* 调试:打印环境变量注入结果(仅开发环境)
|
||||||
|
|||||||
@@ -218,17 +218,27 @@ async def _send_once_async(*, client_user_id: str, local_date: date, slot_index:
|
|||||||
return {"status": "noop", "reason": "no_log"}
|
return {"status": "noop", "reason": "no_log"}
|
||||||
if str(log.status) == "sent":
|
if str(log.status) == "sent":
|
||||||
return {"status": "noop", "reason": "already_sent"}
|
return {"status": "noop", "reason": "already_sent"}
|
||||||
if str(log.status) != "scheduled":
|
if str(log.status) not in ("scheduled", "sending"):
|
||||||
# 例如 failed/skipped/sending:不再重复尝试
|
# 例如 failed/skipped:不再重复尝试
|
||||||
return {"status": "noop", "reason": f"not_scheduled:{log.status}"}
|
return {"status": "noop", "reason": f"not_retryable:{log.status}"}
|
||||||
|
|
||||||
# 原子抢占:避免同一条 scheduled 被重复发送(例如 ETA 任务丢失后被补偿重投递)
|
# 原子抢占:避免重复发送
|
||||||
# 只有从 scheduled -> sending 抢占成功的任务才能继续执行。
|
# - scheduled:正常抢占 scheduled -> sending
|
||||||
|
# - sending:如果长时间卡在 sending(进程崩溃/网络异常等),允许“超时接管”继续执行
|
||||||
|
now_utc_naive = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
steal_cutoff = now_utc_naive - timedelta(minutes=10)
|
||||||
res = await session.execute(
|
res = await session.execute(
|
||||||
update(PushSendLog)
|
update(PushSendLog)
|
||||||
.where(
|
.where(
|
||||||
PushSendLog.id == log.id,
|
PushSendLog.id == log.id,
|
||||||
PushSendLog.status == "scheduled",
|
(
|
||||||
|
(PushSendLog.status == "scheduled")
|
||||||
|
| (
|
||||||
|
(PushSendLog.status == "sending")
|
||||||
|
& (PushSendLog.sent_at.is_(None))
|
||||||
|
& (PushSendLog.scheduled_at <= steal_cutoff)
|
||||||
|
)
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.values(status="sending", error=None)
|
.values(status="sending", error=None)
|
||||||
)
|
)
|
||||||
@@ -262,71 +272,72 @@ async def _send_once_async(*, client_user_id: str, local_date: date, slot_index:
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
return {"status": "failed", "reason": "no_active_token"}
|
return {"status": "failed", "reason": "no_active_token"}
|
||||||
|
|
||||||
# 4) 生成文案(复用推荐模块 push 场景)
|
|
||||||
reco_locale = str(normalize_locale(_pick_reco_locale(pref.locale)))
|
|
||||||
title = _pick_title(reco_locale)
|
|
||||||
|
|
||||||
if pref.user_profile_json:
|
|
||||||
user_profile = UserProfileV1_2.model_validate(pref.user_profile_json)
|
|
||||||
else:
|
|
||||||
# 无画像:用“全跳过”的默认画像(降个性化/降风险)
|
|
||||||
user_profile = UserProfileV1_2.model_validate(
|
|
||||||
build_user_profile_from_questionnaire(QuestionnaireAnswersV1_2()).model_dump()
|
|
||||||
)
|
|
||||||
|
|
||||||
# 直接复用 reco 的 Celery 任务实现(同步函数)
|
|
||||||
from app.tasks.reco import generate as reco_generate
|
|
||||||
|
|
||||||
reco_payload = reco_generate(scene="push", user_profile=user_profile.model_dump(), k=1, locale=reco_locale)
|
|
||||||
body = ""
|
|
||||||
try:
|
try:
|
||||||
items = (reco_payload or {}).get("items") or []
|
# 4) 生成文案(复用推荐模块 push 场景)
|
||||||
if items and isinstance(items, list):
|
reco_locale = str(normalize_locale(_pick_reco_locale(pref.locale)))
|
||||||
body = str(items[0].get("text") or "").strip()
|
title = _pick_title(reco_locale)
|
||||||
except Exception:
|
|
||||||
|
if pref.user_profile_json:
|
||||||
|
user_profile = UserProfileV1_2.model_validate(pref.user_profile_json)
|
||||||
|
else:
|
||||||
|
# 无画像:用“全跳过”的默认画像(降个性化/降风险)
|
||||||
|
user_profile = UserProfileV1_2.model_validate(
|
||||||
|
build_user_profile_from_questionnaire(QuestionnaireAnswersV1_2()).model_dump()
|
||||||
|
)
|
||||||
|
|
||||||
|
# 关键:这里不能调用 tasks.reco.generate(内部会 asyncio.run),否则会嵌套事件循环崩溃。
|
||||||
|
from app.tasks.reco import run_reco_payload_async
|
||||||
|
|
||||||
body = ""
|
body = ""
|
||||||
|
try:
|
||||||
|
reco_payload = await run_reco_payload_async(scene="push", user_profile=user_profile, k=1, locale=reco_locale)
|
||||||
|
items = (reco_payload or {}).get("items") or []
|
||||||
|
if items and isinstance(items, list):
|
||||||
|
body = str(items[0].get("text") or "").strip()
|
||||||
|
except Exception:
|
||||||
|
body = ""
|
||||||
|
|
||||||
if not body:
|
if not body:
|
||||||
body = "给自己一句温柔的话。"
|
body = "给自己一句温柔的话。"
|
||||||
|
|
||||||
# 5) 发送
|
# 5) 发送
|
||||||
try:
|
|
||||||
expo_res = await _send_expo_push(
|
expo_res = await _send_expo_push(
|
||||||
to=str(token.push_token),
|
to=str(token.push_token),
|
||||||
title=title,
|
title=title,
|
||||||
body=body,
|
body=body,
|
||||||
data={"client_user_id": client_user_id, "scene": "push"},
|
data={"client_user_id": client_user_id, "scene": "push"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 6) 解析 Expo 回执,必要时停用 token
|
||||||
|
try:
|
||||||
|
data_list = (expo_res or {}).get("data") or []
|
||||||
|
if data_list and isinstance(data_list, list):
|
||||||
|
first = data_list[0] or {}
|
||||||
|
if first.get("status") == "error":
|
||||||
|
details = first.get("details") or {}
|
||||||
|
err = str(details.get("error") or first.get("message") or "expo_error")
|
||||||
|
log.status = "failed"
|
||||||
|
log.error = err
|
||||||
|
if "DeviceNotRegistered" in err:
|
||||||
|
token.is_active = False
|
||||||
|
await session.commit()
|
||||||
|
return {"status": "failed", "expo": expo_res}
|
||||||
|
except Exception:
|
||||||
|
# 忽略解析异常,继续按成功处理
|
||||||
|
pass
|
||||||
|
|
||||||
|
log.status = "sent"
|
||||||
|
log.sent_at = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
log.error = None
|
||||||
|
await session.commit()
|
||||||
|
return {"status": "sent", "expo": expo_res}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
# 兜底:任何未预期异常都不要让状态卡在 sending
|
||||||
log.status = "failed"
|
log.status = "failed"
|
||||||
log.error = f"send_failed:{type(e).__name__}"
|
log.error = f"unexpected:{type(e).__name__}"
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return {"status": "failed", "error": str(e)}
|
return {"status": "failed", "error": str(e)}
|
||||||
|
|
||||||
# 6) 解析 Expo 回执,必要时停用 token
|
|
||||||
try:
|
|
||||||
data_list = (expo_res or {}).get("data") or []
|
|
||||||
if data_list and isinstance(data_list, list):
|
|
||||||
first = data_list[0] or {}
|
|
||||||
if first.get("status") == "error":
|
|
||||||
details = first.get("details") or {}
|
|
||||||
err = str(details.get("error") or first.get("message") or "expo_error")
|
|
||||||
log.status = "failed"
|
|
||||||
log.error = err
|
|
||||||
if "DeviceNotRegistered" in err:
|
|
||||||
token.is_active = False
|
|
||||||
await session.commit()
|
|
||||||
return {"status": "failed", "expo": expo_res}
|
|
||||||
except Exception:
|
|
||||||
# 忽略解析异常,继续按成功处理
|
|
||||||
pass
|
|
||||||
|
|
||||||
log.status = "sent"
|
|
||||||
log.sent_at = datetime.now(timezone.utc).replace(tzinfo=None)
|
|
||||||
log.error = None
|
|
||||||
await session.commit()
|
|
||||||
return {"status": "sent", "expo": expo_res}
|
|
||||||
|
|
||||||
|
|
||||||
@shared_task(name="tasks.push.send_scheduled")
|
@shared_task(name="tasks.push.send_scheduled")
|
||||||
def send_scheduled(*, client_user_id: str, local_date: str, slot_index: int) -> dict[str, Any]:
|
def send_scheduled(*, client_user_id: str, local_date: str, slot_index: int) -> dict[str, Any]:
|
||||||
@@ -356,7 +367,11 @@ def requeue_overdue(*, grace_seconds: int = 300, limit: int = 200) -> dict[str,
|
|||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
q = (
|
q = (
|
||||||
select(PushSendLog)
|
select(PushSendLog)
|
||||||
.where(PushSendLog.status == "scheduled", PushSendLog.scheduled_at <= cutoff)
|
.where(
|
||||||
|
PushSendLog.status.in_(("scheduled", "sending")),
|
||||||
|
PushSendLog.sent_at.is_(None),
|
||||||
|
PushSendLog.scheduled_at <= cutoff,
|
||||||
|
)
|
||||||
.order_by(PushSendLog.scheduled_at.asc())
|
.order_by(PushSendLog.scheduled_at.asc())
|
||||||
.limit(int(limit))
|
.limit(int(limit))
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -53,6 +53,45 @@ async def _run_reco_async(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_reco_payload_async(
|
||||||
|
*,
|
||||||
|
scene: Scene,
|
||||||
|
user_profile: UserProfileV1_2,
|
||||||
|
already_recommended_ids: Optional[list[Any]] = None,
|
||||||
|
touched_or_viewed_ids: Optional[list[Any]] = None,
|
||||||
|
k: Optional[int] = None,
|
||||||
|
now: Optional[datetime] = None,
|
||||||
|
locale: Optional[str] = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
在“已有事件循环”内运行推荐并返回 payload。
|
||||||
|
|
||||||
|
用途:
|
||||||
|
- 供 Push 等 async 任务内部调用,避免 `asyncio.run()` 嵌套导致 RuntimeError
|
||||||
|
- 也便于未来在 API/任务间复用
|
||||||
|
"""
|
||||||
|
|
||||||
|
effective_now = _ensure_now(now)
|
||||||
|
effective_locale = _ensure_locale(locale)
|
||||||
|
|
||||||
|
# k 默认按场景(与 generate 保持一致)
|
||||||
|
if k is None:
|
||||||
|
k_i = 30 if scene == "feed" else 1
|
||||||
|
else:
|
||||||
|
k_i = int(k)
|
||||||
|
|
||||||
|
result = await _run_reco_async(
|
||||||
|
scene=scene,
|
||||||
|
user_profile=user_profile,
|
||||||
|
already_recommended_ids=list(already_recommended_ids or []),
|
||||||
|
touched_or_viewed_ids=list(touched_or_viewed_ids or []),
|
||||||
|
k=int(k_i),
|
||||||
|
now=effective_now,
|
||||||
|
locale=effective_locale,
|
||||||
|
)
|
||||||
|
return result.model_dump()
|
||||||
|
|
||||||
|
|
||||||
def _run_reco_sync(
|
def _run_reco_sync(
|
||||||
*,
|
*,
|
||||||
scene: Scene,
|
scene: Scene,
|
||||||
|
|||||||
BIN
server/celerybeat-schedule-shm
Normal file
BIN
server/celerybeat-schedule-shm
Normal file
Binary file not shown.
BIN
server/celerybeat-schedule-wal
Normal file
BIN
server/celerybeat-schedule-wal
Normal file
Binary file not shown.
Reference in New Issue
Block a user