fix: schedule timezone bug + missing notifications + celery beat startup

- Timezone: compute_next_run now correctly interprets cron in the schedule's
  configured timezone (e.g., "0 8 * * *" with Asia/Shanghai = 8AM Beijing, not UTC)
- Notifications: agent_tasks now reuses pre-created execution records and calls
  notify_schedule_result on completion, so non-workflow agent schedules get
  DB notifications + Feishu webhook + Feishu app messages
- Duplicate execution: execute_agent_task accepts optional execution_id to reuse
  the record created by schedule_service instead of creating a second one
- Celery Beat: added to restart_backend_celery.ps1, stop_aiagent.ps1, and
  docker-compose.dev.yml; fixed repo-root path resolution in all PS1 scripts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
renjianbo
2026-05-05 08:57:00 +08:00
parent 592bca4f39
commit 0606137d57
9 changed files with 600 additions and 110 deletions

View File

@@ -95,7 +95,7 @@ async def create_schedule(
# 验证 cron 表达式 # 验证 cron 表达式
try: try:
next_run = compute_next_run(data.cron_expression) next_run = compute_next_run(data.cron_expression, tz=data.timezone or "Asia/Shanghai")
except (ValueError, KeyError) as e: except (ValueError, KeyError) as e:
raise HTTPException(status_code=400, detail=f"cron 表达式无效: {e}") raise HTTPException(status_code=400, detail=f"cron 表达式无效: {e}")
@@ -138,7 +138,7 @@ async def update_schedule(
schedule.name = data.name schedule.name = data.name
if data.cron_expression is not None: if data.cron_expression is not None:
try: try:
schedule.next_run_at = compute_next_run(data.cron_expression, after=datetime.utcnow()) schedule.next_run_at = compute_next_run(data.cron_expression, after=datetime.utcnow(), tz=data.timezone or schedule.timezone or "Asia/Shanghai")
schedule.cron_expression = data.cron_expression schedule.cron_expression = data.cron_expression
except (ValueError, KeyError) as e: except (ValueError, KeyError) as e:
raise HTTPException(status_code=400, detail=f"cron 表达式无效: {e}") raise HTTPException(status_code=400, detail=f"cron 表达式无效: {e}")

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import logging import logging
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from typing import Optional from typing import Optional
import pytz
from croniter import croniter from croniter import croniter
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -12,22 +13,33 @@ from app.core.database import SessionLocal
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def compute_next_run(cron_expression: str, after: Optional[datetime] = None) -> datetime: def compute_next_run(cron_expression: str, after: Optional[datetime] = None, tz: str = "UTC") -> datetime:
"""根据 cron 表达式计算下一次执行时间。 """根据 cron 表达式计算下一次执行时间。
Args: Args:
cron_expression: 标准 5 位 cron 表达式,如 "0 9 * * *" cron_expression: 标准 5 位 cron 表达式,如 "0 9 * * *"
after: 从哪个时间点开始计算,默认为当前 UTC 时间 after: 从哪个时间点开始计算,默认为当前 UTC 时间
tz: 时区名称,如 "Asia/Shanghai",cron 表达式在该时区下解释
Returns: Returns:
下次执行的 UTC datetime 下次执行的 UTC datetime(naive,存储时与 datetime.utcnow() 对齐)
""" """
base = after or datetime.now(timezone.utc) base = after or datetime.now(timezone.utc)
# croniter 需要以 datetime 对象作为基准 # 将 UTC 时间转换为目标时区,cron 表达式在目标时区下解释
base_naive = base.replace(tzinfo=None) if base.tzinfo else base try:
target_tz = pytz.timezone(tz)
except pytz.UnknownTimeZoneError:
target_tz = pytz.UTC
base_in_tz = base.astimezone(target_tz)
# croniter 需要 naive datetime,在目标时区下计算
base_naive = base_in_tz.replace(tzinfo=None)
cron = croniter(cron_expression, base_naive) cron = croniter(cron_expression, base_naive)
next_dt = cron.get_next(datetime) next_naive = cron.get_next(datetime)
return next_dt # 将结果 localize 回目标时区,再转 UTC
next_in_tz = target_tz.localize(next_naive)
next_utc = next_in_tz.astimezone(pytz.UTC)
# 返回 naive UTC(与 datetime.utcnow() 对齐)
return next_utc.replace(tzinfo=None)
def create_execution_for_schedule(db: Session, schedule) -> Optional[str]: def create_execution_for_schedule(db: Session, schedule) -> Optional[str]:
@@ -69,11 +81,12 @@ def create_execution_for_schedule(db: Session, schedule) -> Optional[str]:
{"message": schedule.input_message}, {"message": schedule.input_message},
) )
else: else:
# 无工作流配置:走简单 Agent 异步执行 # 无工作流配置:走简单 Agent 异步执行(传入已创建的 execution_id)
from app.tasks.agent_tasks import execute_agent_task from app.tasks.agent_tasks import execute_agent_task
task = execute_agent_task.delay( task = execute_agent_task.delay(
str(schedule.agent_id), str(schedule.agent_id),
{"message": schedule.input_message}, {"message": schedule.input_message},
execution_id=str(execution.id),
) )
execution.task_id = task.id execution.task_id = task.id
execution.status = "running" execution.status = "running"
@@ -124,8 +137,8 @@ def check_and_run_due_schedules() -> int:
# 更新定时任务状态 # 更新定时任务状态
sched.last_run_at = now sched.last_run_at = now
sched.last_run_status = "success" sched.last_run_status = "success"
# 计算下次执行时间 # 计算下次执行时间(使用定时任务配置的时区)
sched.next_run_at = compute_next_run(sched.cron_expression, after=now) sched.next_run_at = compute_next_run(sched.cron_expression, after=now, tz=sched.timezone or "UTC")
db.commit() db.commit()
triggered += 1 triggered += 1
logger.info( logger.info(
@@ -145,3 +158,91 @@ def check_and_run_due_schedules() -> int:
finally: finally:
if db: if db:
db.close() db.close()
def notify_schedule_result(db: Session, execution, status: str, error_message: Optional[str] = None) -> None:
"""如果 execution 关联了定时任务,创建通知并推送飞书消息。
Args:
db: 数据库会话
execution: Execution ORM 对象
status: "completed" 或 "failed"
error_message: 失败时的错误信息
"""
if not execution or not execution.schedule_id:
return
try:
from app.models.agent_schedule import AgentSchedule
from app.services.notification_service import create_notification
schedule = db.query(AgentSchedule).filter(AgentSchedule.id == execution.schedule_id).first()
if not schedule:
return
if status == "completed":
title = f"定时任务「{schedule.name}」执行成功"
content = f"Agent 已按计划执行完成。"
else:
title = f"定时任务「{schedule.name}」执行失败"
content = f"错误信息: {error_message or '未知错误'}"
create_notification(
db,
user_id=schedule.user_id,
title=title,
content=content,
category="schedule",
ref_type="execution",
ref_id=str(execution.id),
)
db.commit()
# 如果配置了飞书 webhook,发送飞书通知(非阻塞,失败不影响主流程)
if schedule.webhook_url:
try:
from app.services.feishu_notifier import send_feishu_card
detail_link = None
try:
from app.core.config import settings
if settings.EXTERNAL_URL:
detail_link = f"{settings.EXTERNAL_URL}/executions/{execution.id}"
except Exception:
pass
send_feishu_card(
webhook_url=schedule.webhook_url,
title=title,
body=content,
status=status,
detail_link=detail_link,
)
except Exception as e:
logger.warning("飞书 webhook 通知发送失败: %s", e)
# 如果用户绑定了飞书账号,通过飞书应用发送通知
try:
from app.models.user import User
from app.services.feishu_app_service import send_message_to_user
schedule_user = db.query(User).filter(User.id == schedule.user_id).first()
if schedule_user and schedule_user.feishu_open_id:
detail_link = None
try:
from app.core.config import settings
if settings.EXTERNAL_URL:
detail_link = f"{settings.EXTERNAL_URL}/executions/{execution.id}"
except Exception:
pass
send_message_to_user(
open_id=schedule_user.feishu_open_id,
title=title,
content=content,
status=status,
detail_link=detail_link,
)
except Exception as e:
logger.warning("飞书应用通知发送失败: %s", e)
except Exception as e:
logger.warning("创建定时任务通知失败: %s", e)

View File

@@ -21,6 +21,7 @@ from app.models.execution import Execution
import asyncio import asyncio
import logging import logging
import time import time
from typing import Optional
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -77,16 +78,20 @@ def _build_agent_config_from_db(agent: Agent) -> AgentConfig:
@celery_app.task(bind=True) @celery_app.task(bind=True)
def execute_agent_task(self, agent_id: str, input_data: dict): def execute_agent_task(self, agent_id: str, input_data: dict, execution_id: Optional[str] = None):
"""异步执行 Agent 任务。 """异步执行 Agent 任务。
由定时调度 (check_agent_schedules_task) 或手动 API 触发。 由定时调度 (check_agent_schedules_task) 或手动 API 触发。
创建 Execution 记录,运行 Agent,更新结果。 如果传入 execution_id(由 schedule_service 预创建),则复用该记录;
否则创建新的 Execution 记录。运行 Agent 后更新结果并发送通知。
Args: Args:
agent_id: Agent ID agent_id: Agent ID
input_data: 输入数据,至少包含 "message" 字段 input_data: 输入数据,至少包含 "message" 字段
execution_id: 可选,预创建的 execution 记录 ID(定时任务场景)
""" """
from app.services.agent_schedule_service import notify_schedule_result
db = SessionLocal() db = SessionLocal()
start_time = time.time() start_time = time.time()
@@ -99,14 +104,21 @@ def execute_agent_task(self, agent_id: str, input_data: dict):
if not user_message: if not user_message:
return {"status": "error", "detail": "缺少 message 输入"} return {"status": "error", "detail": "缺少 message 输入"}
# 创建执行记录 # 复用预创建的 execution 记录,或新建
execution = Execution( if execution_id:
agent_id=agent_id, execution = db.query(Execution).filter(Execution.id == execution_id).first()
input_data=input_data, if not execution:
status="running", return {"status": "error", "detail": f"Execution {execution_id} 不存在"}
) execution.status = "running"
db.add(execution) db.commit()
db.flush() else:
execution = Execution(
agent_id=agent_id,
input_data=input_data,
status="running",
)
db.add(execution)
db.flush()
# 更新 Celery 任务状态 # 更新 Celery 任务状态
self.update_state( self.update_state(
@@ -137,6 +149,9 @@ def execute_agent_task(self, agent_id: str, input_data: dict):
execution.execution_time = execution_time execution.execution_time = execution_time
db.commit() db.commit()
# 定时任务结果通知
notify_schedule_result(db, execution, "completed")
logger.info( logger.info(
"Agent 异步执行完成: agent=%s execution=%s time=%dms", "Agent 异步执行完成: agent=%s execution=%s time=%dms",
agent_id, execution.id, execution_time, agent_id, execution.id, execution_time,
@@ -154,6 +169,10 @@ def execute_agent_task(self, agent_id: str, input_data: dict):
execution.error_message = result.error or "Agent 执行返回失败" execution.error_message = result.error or "Agent 执行返回失败"
execution.execution_time = int((time.time() - start_time) * 1000) execution.execution_time = int((time.time() - start_time) * 1000)
db.commit() db.commit()
# 定时任务失败通知
notify_schedule_result(db, execution, "failed", error_message=execution.error_message)
return { return {
"status": "failed", "status": "failed",
"execution_id": str(execution.id), "execution_id": str(execution.id),
@@ -165,6 +184,10 @@ def execute_agent_task(self, agent_id: str, input_data: dict):
execution.error_message = f"Agent 执行异常: {run_e!s}" execution.error_message = f"Agent 执行异常: {run_e!s}"
execution.execution_time = execution_time execution.execution_time = execution_time
db.commit() db.commit()
# 定时任务失败通知
notify_schedule_result(db, execution, "failed", error_message=execution.error_message)
logger.error("Agent 异步执行异常: agent=%s error=%s", agent_id, run_e) logger.error("Agent 异步执行异常: agent=%s error=%s", agent_id, run_e)
raise raise

View File

@@ -19,7 +19,7 @@ from app.models.agent import Agent
from app.models.workflow import Workflow from app.models.workflow import Workflow
from app.services.execution_budget import merge_budget_for_execution from app.services.execution_budget import merge_budget_for_execution
from app.services.agent_workspace_chat_log import try_append_agent_dialogue_after_success from app.services.agent_workspace_chat_log import try_append_agent_dialogue_after_success
from app.services.notification_service import create_notification from app.services.agent_schedule_service import notify_schedule_result
from app.websocket.manager import websocket_manager from app.websocket.manager import websocket_manager
import asyncio import asyncio
import json import json
@@ -80,92 +80,6 @@ def _trusted_user_for_execution(db, execution: Optional[Execution]) -> Optional[
return None return None
def _notify_schedule_result(
db,
execution,
status: str,
error_message: Optional[str] = None,
):
"""如果 execution 关联了定时任务,创建通知推送结果给用户。"""
if not execution or not execution.schedule_id:
return
try:
from app.models.agent_schedule import AgentSchedule
schedule = db.query(AgentSchedule).filter(AgentSchedule.id == execution.schedule_id).first()
if not schedule:
return
if status == "completed":
title = f"定时任务「{schedule.name}」执行成功"
content = f"Agent 已按计划执行完成。"
else:
title = f"定时任务「{schedule.name}」执行失败"
content = f"错误信息: {error_message or '未知错误'}"
create_notification(
db,
user_id=schedule.user_id,
title=title,
content=content,
category="schedule",
ref_type="execution",
ref_id=str(execution.id),
)
db.commit()
# 如果配置了飞书 webhook,发送飞书通知(非阻塞,失败不影响主流程)
if schedule.webhook_url:
try:
from app.services.feishu_notifier import send_feishu_card
detail_link = None
# 如果系统配置了外部访问地址,拼接 execution 详情链接
try:
from app.core.config import settings
if settings.EXTERNAL_URL:
detail_link = f"{settings.EXTERNAL_URL}/executions/{execution.id}"
except Exception:
pass
send_feishu_card(
webhook_url=schedule.webhook_url,
title=title,
body=content,
status=status,
detail_link=detail_link,
)
except Exception as e:
logger.warning("飞书 webhook 通知发送失败: %s", e)
# 如果用户绑定了飞书账号,通过飞书应用发送通知
try:
from app.models.user import User
from app.services.feishu_app_service import send_message_to_user
schedule_user = db.query(User).filter(User.id == schedule.user_id).first()
if schedule_user and schedule_user.feishu_open_id:
detail_link = None
try:
from app.core.config import settings
if settings.EXTERNAL_URL:
detail_link = f"{settings.EXTERNAL_URL}/executions/{execution.id}"
except Exception:
pass
send_message_to_user(
open_id=schedule_user.feishu_open_id,
title=title,
content=content,
status=status,
detail_link=detail_link,
)
except Exception as e:
logger.warning("飞书应用通知发送失败: %s", e)
except Exception as e:
logger.warning("创建定时任务通知失败: %s", e)
@celery_app.task(bind=True) @celery_app.task(bind=True)
def execute_workflow_task( def execute_workflow_task(
self, self,
@@ -287,7 +201,7 @@ def execute_workflow_task(
execution_logger.warn(f"告警检测失败: {str(e)}") execution_logger.warn(f"告警检测失败: {str(e)}")
# 定时任务结果通知 # 定时任务结果通知
_notify_schedule_result(db, execution, "completed") notify_schedule_result(db, execution, "completed")
return { return {
'status': 'completed', 'status': 'completed',
@@ -331,7 +245,7 @@ def execute_workflow_task(
execution_logger.warn(f"告警检测失败: {str(e2)}") execution_logger.warn(f"告警检测失败: {str(e2)}")
# 定时任务失败通知 # 定时任务失败通知
_notify_schedule_result(db, execution, "failed", error_message=err_text) notify_schedule_result(db, execution, "failed", error_message=err_text)
raise raise

View File

@@ -0,0 +1,89 @@
services:
frontend:
build:
context: ./frontend
dockerfile: Dockerfile.dev
ports:
- "8038:3000"
volumes:
- ./frontend:/app
- /app/node_modules
environment:
- VITE_API_URL=http://101.43.95.130:8037
# 注意:Vite环境变量需要在构建时设置,运行时修改需要重启容器
depends_on:
- backend
networks:
- aiagent-network
backend:
build:
context: ./backend
dockerfile: Dockerfile.dev
ports:
- "8037:8000"
volumes:
- ./backend:/app
environment:
- DATABASE_URL=mysql+pymysql://root:!Rjb12191@gz-cynosdbmysql-grp-d26pzce5.sql.tencentcdb.com:24936/agent_db?charset=utf8mb4
- REDIS_URL=redis://redis:6379/0
- SECRET_KEY=dev-secret-key-change-in-production
- CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,http://localhost:8038,http://101.43.95.130:8038
- DEEPSEEK_API_KEY=sk-fdf7cc1c73504e628ec0119b7e11b8cc
- DEEPSEEK_BASE_URL=https://api.deepseek.com
depends_on:
- redis
networks:
- aiagent-network
celery:
build:
context: ./backend
dockerfile: Dockerfile.dev
command: celery -A app.core.celery_app worker --loglevel=info
volumes:
- ./backend:/app
environment:
- DATABASE_URL=mysql+pymysql://root:!Rjb12191@gz-cynosdbmysql-grp-d26pzce5.sql.tencentcdb.com:24936/agent_db?charset=utf8mb4
- REDIS_URL=redis://redis:6379/0
- DEEPSEEK_API_KEY=sk-fdf7cc1c73504e628ec0119b7e11b8cc
- DEEPSEEK_BASE_URL=https://api.deepseek.com
depends_on:
- redis
- backend
networks:
- aiagent-network
celery-beat:
build:
context: ./backend
dockerfile: Dockerfile.dev
command: celery -A app.core.celery_app beat --loglevel=info
volumes:
- ./backend:/app
environment:
- DATABASE_URL=mysql+pymysql://root:!Rjb12191@gz-cynosdbmysql-grp-d26pzce5.sql.tencentcdb.com:24936/agent_db?charset=utf8mb4
- REDIS_URL=redis://redis:6379/0
- DEEPSEEK_API_KEY=sk-fdf7cc1c73504e628ec0119b7e11b8cc
- DEEPSEEK_BASE_URL=https://api.deepseek.com
depends_on:
- redis
- backend
networks:
- aiagent-network
redis:
image: redis:7-alpine
ports:
- "6380:6379" # 主机 6380 映射到容器 6379,避免与宿主机 6379 冲突
volumes:
- redis_data:/data
networks:
- aiagent-network
volumes:
redis_data:
networks:
aiagent-network:
driver: bridge

View File

@@ -0,0 +1,73 @@
# Restart backend API (uvicorn) + Celery worker + Celery Beat (scheduler)
# Does not stop frontend/Redis.
$ErrorActionPreference = "Continue"
# Repo root = two levels up from scripts/startup/
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$RepoRoot = Split-Path -Parent (Split-Path -Parent $ScriptDir)
$Backend = Join-Path $RepoRoot "backend"
$ApiPort = 8037
Write-Host "== Stop API + Celery worker + Celery Beat ==" -ForegroundColor Cyan
function Stop-ByCommandLine([string]$pattern, [string]$name) {
$targets = Get-CimInstance Win32_Process | Where-Object {
$_.CommandLine -and $_.CommandLine -match $pattern
}
if (-not $targets) {
Write-Host "[SKIP] ${name}: no matching process" -ForegroundColor DarkGray
return
}
foreach ($p in $targets) {
try {
Stop-Process -Id $p.ProcessId -Force
Write-Host "[OK] stopped ${name} PID=$($p.ProcessId)" -ForegroundColor Green
} catch {
Write-Host "[WARN] failed to stop ${name} PID=$($p.ProcessId)" -ForegroundColor Yellow
}
}
}
Stop-ByCommandLine "uvicorn\s+app\.main:app" "backend-api"
Stop-ByCommandLine "celery\s+-A\s+app\.core\.celery_app\s+worker" "celery-worker"
Stop-ByCommandLine "celery\s+-A\s+app\.core\.celery_app\s+beat" "celery-beat"
Start-Sleep -Seconds 2
Write-Host "== Start API on $ApiPort + Celery worker + Celery Beat ==" -ForegroundColor Cyan
# 1. API (uvicorn)
Start-Process powershell -ArgumentList @(
"-NoExit",
"-NoProfile",
"-ExecutionPolicy", "Bypass",
"-Command",
"Set-Location '$Backend'; .\venv\Scripts\Activate.ps1; python -m uvicorn app.main:app --host 0.0.0.0 --port $ApiPort"
)
# 2. Celery worker
Start-Process powershell -ArgumentList @(
"-NoExit",
"-NoProfile",
"-ExecutionPolicy", "Bypass",
"-Command",
"Set-Location '$Backend'; .\venv\Scripts\Activate.ps1; python -m celery -A app.core.celery_app worker --loglevel=info --pool=threads --concurrency=8"
)
# 3. Celery Beat (scheduler — 每分钟检查到期的定时任务)
Start-Process powershell -ArgumentList @(
"-NoExit",
"-NoProfile",
"-ExecutionPolicy", "Bypass",
"-Command",
"Set-Location '$Backend'; .\venv\Scripts\Activate.ps1; python -m celery -A app.core.celery_app beat --loglevel=info"
)
Write-Host ""
Write-Host "[DONE] 已在新窗口启动 API + Celery Worker + Celery Beat" -ForegroundColor Green
Write-Host "API: http://127.0.0.1:$ApiPort/docs" -ForegroundColor Cyan
Write-Host "Celery Beat 负责每分钟检查定时任务,如果定时推送没触发,请确认 Beat 窗口正在运行" -ForegroundColor Yellow
Start-Sleep -Seconds 2
Write-Host ""
Write-Host "Port $ApiPort :" -ForegroundColor Cyan
netstat -ano | findstr ":$ApiPort"

View File

@@ -0,0 +1,91 @@
param(
[int]$ApiPort = 8037,
[int]$FallbackApiPort = 8041,
[int]$FrontendPort = 3001
)
$ErrorActionPreference = "Stop"
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$RepoRoot = Split-Path -Parent (Split-Path -Parent $ScriptDir)
$Backend = Join-Path $RepoRoot "backend"
$Frontend = Join-Path $RepoRoot "frontend"
$RedisDir = Join-Path $Backend "redis"
$RedisExe = Join-Path $RedisDir "redis-server.exe"
$RedisCli = Join-Path $RedisDir "redis-cli.exe"
function Test-PortListening([int]$Port) {
$line = netstat -ano | Select-String ":$Port\s+.*LISTENING" | Select-Object -First 1
return [bool]$line
}
function Ensure-Redis {
if (Test-PortListening 6379) {
Write-Host '[OK] Redis already listening on 6379' -ForegroundColor Green
return
}
if (-not (Test-Path $RedisExe)) {
throw "Redis executable not found: $RedisExe"
}
Write-Host '[RUN] Starting Redis on 6379 ...' -ForegroundColor Yellow
Start-Process -FilePath $RedisExe -ArgumentList "--port 6379" -WorkingDirectory $RedisDir | Out-Null
Start-Sleep -Seconds 2
if (-not (Test-PortListening 6379)) {
throw "Redis failed: port 6379 not listening"
}
if (Test-Path $RedisCli) {
& $RedisCli -p 6379 ping | Out-Null
}
Write-Host '[OK] Redis started' -ForegroundColor Green
}
function Resolve-ApiPort {
if (-not (Test-PortListening $ApiPort)) {
return $ApiPort
}
Write-Host ('[WARN] Port {0} is occupied, switching to {1}' -f $ApiPort, $FallbackApiPort) -ForegroundColor Yellow
if (Test-PortListening $FallbackApiPort) {
throw "Ports $ApiPort and $FallbackApiPort are in use; free one first"
}
return $FallbackApiPort
}
Write-Host '== AIAgent one-click start ==' -ForegroundColor Cyan
Write-Host "Repo: $RepoRoot"
Ensure-Redis
$RealApiPort = Resolve-ApiPort
$ApiBase = "http://127.0.0.1:$RealApiPort"
Write-Host ('[RUN] Starting backend API on {0} ...' -f $RealApiPort) -ForegroundColor Yellow
Start-Process powershell -ArgumentList @(
"-NoExit",
"-Command",
"cd '$Backend'; .\venv\Scripts\Activate.ps1; python -m uvicorn app.main:app --host 0.0.0.0 --port $RealApiPort"
)
Write-Host '[RUN] Starting Celery worker ...' -ForegroundColor Yellow
Start-Process powershell -ArgumentList @(
"-NoExit",
"-Command",
"cd '$Backend'; .\venv\Scripts\Activate.ps1; python -m celery -A app.core.celery_app worker --loglevel=info --pool=threads --concurrency=8"
)
Write-Host '[RUN] Starting Celery Beat (scheduler) ...' -ForegroundColor Yellow
Start-Process powershell -ArgumentList @(
"-NoExit",
"-Command",
"cd '$Backend'; .\venv\Scripts\Activate.ps1; python -m celery -A app.core.celery_app beat --loglevel=info"
)
Write-Host ('[RUN] Starting frontend on {0} (proxy -> {1}) ...' -f $FrontendPort, $ApiBase) -ForegroundColor Yellow
Start-Process powershell -ArgumentList @(
"-NoExit",
"-Command",
"`$env:AIAGENT_API_PROXY='$ApiBase'; cd '$Frontend'; pnpm dev --port $FrontendPort"
)
Write-Host ""
Write-Host '[DONE] Start commands issued (check new PowerShell windows)' -ForegroundColor Green
Write-Host "Frontend: http://localhost:$FrontendPort" -ForegroundColor Cyan
Write-Host "API docs: $ApiBase/docs" -ForegroundColor Cyan
Write-Host "Redis: 127.0.0.1:6379" -ForegroundColor Cyan

View File

@@ -0,0 +1,112 @@
# 静默后台启动脚本(无弹窗,日志写文件)
# 用于 Windows 任务计划程序开机自启
$ErrorActionPreference = "Continue"
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$RepoRoot = Split-Path -Parent (Split-Path -Parent $ScriptDir)
$Backend = Join-Path $RepoRoot "backend"
$Frontend = Join-Path $RepoRoot "frontend"
$RedisDir = Join-Path $Backend "redis"
$RedisExe = Join-Path $RedisDir "redis-server.exe"
$RedisCli = Join-Path $RedisDir "redis-cli.exe"
$LogDir = Join-Path $RepoRoot "logs"
$LogFile = Join-Path $LogDir "autostart_$(Get-Date -Format 'yyyyMMdd_HHmmss').log"
if (-not (Test-Path $LogDir)) {
New-Item -ItemType Directory -Path $LogDir -Force | Out-Null
}
function Write-Log($msg) {
$ts = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
"$ts $msg" | Out-File -Append -FilePath $LogFile -Encoding UTF8
}
Write-Log "========== AIAgent background start =========="
Write-Log "Repo: $RepoRoot"
# ── 1) Redis ──────────────────────────────────
$redisPort = 6379
$redisListening = netstat -ano | Select-String ":$redisPort\s+.*LISTENING"
if ($redisListening) {
Write-Log "Redis already listening on $redisPort"
} else {
if (-not (Test-Path $RedisExe)) {
Write-Log "ERROR: Redis not found at $RedisExe"
} else {
Write-Log "Starting Redis on $redisPort ..."
$redisProc = Start-Process -FilePath $RedisExe `
-ArgumentList "--port $redisPort" `
-WorkingDirectory $RedisDir `
-WindowStyle Hidden `
-PassThru
Start-Sleep -Seconds 2
if (Test-Path $RedisCli) {
& $RedisCli -p $redisPort ping 2>&1 | Out-File -Append $LogFile -Encoding UTF8
}
Write-Log "Redis PID=$($redisProc.Id) started"
}
}
# ── 2) Backend API ────────────────────────────
$apiPort = 8037
$apiListening = netstat -ano | Select-String ":$apiPort\s+.*LISTENING"
if ($apiListening) {
Write-Log "Backend API already listening on $apiPort"
} else {
Write-Log "Starting backend API on $apiPort ..."
$apiLog = Join-Path $LogDir "api.log"
Start-Process powershell `
-WindowStyle Hidden `
-ArgumentList @(
"-NoProfile", "-ExecutionPolicy", "Bypass",
"-Command",
"Set-Location '$Backend'; .\venv\Scripts\Activate.ps1; python -m uvicorn app.main:app --host 0.0.0.0 --port $apiPort 2>&1 | Out-File -Append '$apiLog' -Encoding UTF8"
)
Write-Log "Backend API starting (log: $apiLog)"
}
# ── 3) Celery Worker ──────────────────────────
Write-Log "Starting Celery worker ..."
$celeryLog = Join-Path $LogDir "celery.log"
Start-Process powershell `
-WindowStyle Hidden `
-ArgumentList @(
"-NoProfile", "-ExecutionPolicy", "Bypass",
"-Command",
"Set-Location '$Backend'; .\venv\Scripts\Activate.ps1; python -m celery -A app.core.celery_app worker --loglevel=info --pool=threads --concurrency=8 2>&1 | Out-File -Append '$celeryLog' -Encoding UTF8"
)
Write-Log "Celery worker starting (log: $celeryLog)"
# ── 3.5) Celery Beat (scheduler) ──────────────
Write-Log "Starting Celery Beat ..."
$beatLog = Join-Path $LogDir "celery_beat.log"
Start-Process powershell `
-WindowStyle Hidden `
-ArgumentList @(
"-NoProfile", "-ExecutionPolicy", "Bypass",
"-Command",
"Set-Location '$Backend'; .\venv\Scripts\Activate.ps1; python -m celery -A app.core.celery_app beat --loglevel=info 2>&1 | Out-File -Append '$beatLog' -Encoding UTF8"
)
Write-Log "Celery Beat starting (log: $beatLog)"
# ── 4) Frontend (pnpm dev) ────────────────────
$frontendPort = 3001
$frontendListening = netstat -ano | Select-String ":$frontendPort\s+.*LISTENING"
if ($frontendListening) {
Write-Log "Frontend already listening on $frontendPort"
} else {
Write-Log "Starting frontend on $frontendPort ..."
$frontendLog = Join-Path $LogDir "frontend.log"
Start-Process powershell `
-WindowStyle Hidden `
-ArgumentList @(
"-NoProfile", "-ExecutionPolicy", "Bypass",
"-Command",
"`$env:AIAGENT_API_PROXY='http://127.0.0.1:$apiPort'; Set-Location '$Frontend'; pnpm dev --port $frontendPort 2>&1 | Out-File -Append '$frontendLog' -Encoding UTF8"
)
Write-Log "Frontend starting (log: $frontendLog)"
}
Write-Log "========== All services issued =========="
Write-Log "Frontend: http://localhost:$frontendPort"
Write-Log "API docs: http://127.0.0.1:$apiPort/docs"

View File

@@ -0,0 +1,87 @@
$ErrorActionPreference = "SilentlyContinue"
Write-Host "== AIAgent stop ==" -ForegroundColor Cyan
function Get-PidsListeningOnPort([int]$Port) {
$pids = New-Object System.Collections.Generic.HashSet[int]
try {
netstat -ano | ForEach-Object {
$ln = $_.Trim()
if ($ln -notmatch "LISTENING") { return }
# 匹配 :8037 或 :3001 等端口后的 LISTENING 行末 PID
if ($ln -match ":$Port\s+.*LISTENING\s+(\d+)\s*$") {
[void]$pids.Add([int]$Matches[1])
}
}
} catch { }
return @($pids)
}
function Stop-OnPorts([int[]]$ports, [string]$name) {
$all = New-Object System.Collections.Generic.HashSet[int]
foreach ($p in $ports) {
foreach ($pid in (Get-PidsListeningOnPort $p)) {
[void]$all.Add($pid)
}
}
if ($all.Count -eq 0) {
Write-Host "[SKIP] ${name}: no listener on ports $($ports -join ',')" -ForegroundColor DarkGray
return
}
foreach ($pid in $all) {
if ($pid -le 4) { continue }
try {
Stop-Process -Id $pid -Force -ErrorAction SilentlyContinue
Write-Host "[OK] stopped ${name} PID=$pid" -ForegroundColor Green
} catch {
Write-Host "[WARN] failed to stop ${name} PID=$pid" -ForegroundColor Yellow
}
}
}
# 后端 API(8037 / 8041 备用)
Stop-OnPorts @(8037, 8041) "backend-api"
# 前端 Vite(3001)
Stop-OnPorts @(3001) "frontend-dev"
# Redis(6379,仅当监听在本地开发端口时结束;若与其它项目共用请谨慎)
Stop-OnPorts @(6379) "redis"
# Celery Worker / Beat(不监听端口,需要按命令行匹配)
$celeryPatterns = @(
@{Pattern='celery\s+-A\s+app\.core\.celery_app\s+worker'; Name='celery-worker'},
@{Pattern='celery\s+-A\s+app\.core\.celery_app\s+beat'; Name='celery-beat'}
)
foreach ($cp in $celeryPatterns) {
$targets = Get-CimInstance Win32_Process | Where-Object {
$_.CommandLine -and $_.CommandLine -match $cp.Pattern
}
if (-not $targets) {
Write-Host "[SKIP] $($cp.Name): no matching process" -ForegroundColor DarkGray
}
foreach ($p in $targets) {
try {
Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue
Write-Host "[OK] stopped $($cp.Name) PID=$($p.ProcessId)" -ForegroundColor Green
} catch {
Write-Host "[WARN] failed to stop $($cp.Name) PID=$($p.ProcessId)" -ForegroundColor Yellow
}
}
}
Start-Sleep -Milliseconds 600
Write-Host ""
Write-Host "Port check:" -ForegroundColor Cyan
foreach ($port in 3001, 8037, 8041, 6379) {
$line = netstat -ano | Select-String ":$port\s+.*LISTENING" | Select-Object -First 1
if ($line) {
Write-Host " - ${port}: LISTEN" -ForegroundColor Yellow
} else {
Write-Host " - ${port}: free" -ForegroundColor Green
}
}
Write-Host ""
Write-Host "DONE: stop script finished" -ForegroundColor Green