diff --git a/.gitea/ai-review.md b/.gitea/ai-review.md new file mode 100644 index 0000000..9d9f674 --- /dev/null +++ b/.gitea/ai-review.md @@ -0,0 +1,27 @@ +# fa-agent (TraceIQ 财务分析) review profile + +## Stack and verification + +- Django 5.2 后端(PostgreSQL 生产 / SQLite 仅开发),金额一律 `NUMERIC(44,24)` + Python `Decimal`(精度 60);JSON 中金额为字符串。 +- React 19 + TypeScript + Vite 前端(AG Grid、ECharts),`npm run build` 含 `tsc --noEmit`。 +- openpyxl 流式生成公式工作簿(SUMIFS/COUNTIFS),公式正确性靠 LibreOffice 无头重算验证。 +- pytest(`--cov-fail-under=85`)+ Playwright E2E;CI 在 PostgreSQL 17 服务容器上跑全量。 +- 验证命令:`uv run pytest`、`uv run ruff check server finance tests scripts`、`npm run typecheck`、`python manage.py makemigrations --check`。 + +## Review priorities + +1. **金额精度**:财务计算、持久化、Excel 公式生成路径上出现 `float()`、`round()`、Decimal 与 float 混算、或金额以数字(非字符串)进 JSON,必须报。浏览器 `Number` 只允许用于绘图与显示。 +2. **缺失值语义**:成本/毛利/人数等依赖字段缺失时必须传播为不可用(服务端 `None`、Excel `#N/A`),不得静默转 0 或跳过;比率分母为零/非正同样不可用。对账容差按指标类型独立(人数严格相等、金额 0.01、比率 0.0001),不得为消除报错统一放宽容差。 +3. **文件与路径安全**:清理/下载/回收文件前必须 `lstat` 校验且拒绝符号链接(目录用 `O_NOFOLLOW` 打开),只删除本次任务在受控目录内创建的文件;清理失败要留日志与可追踪状态,不得静默吞掉 `OSError`。 +4. **越权与生命周期**:每个 endpoint 必须声明 roles 白名单;worker 执行阶段要重新核验角色、`is_active`、组织与科室范围(不能只信创建时的检查);卡死任务要有超时恢复,且恢复不得与活跃 worker 抢任务。 +5. **事务与并发**:配额/唯一性检查必须在事务内并锁定稳定的所属行(如 Membership),审计与业务变更同事务提交;乐观并发用 revision + 409,不得静默后写覆盖。 +6. **Excel 公式注入与上限**:不可信文本写入单元格必须强制文本类型;CSV/公式中 `= + - @` 开头加 `'` 前缀;SUMIFS/COUNTIFS criteria 必须转义 `~ * ? "` 并检查 255 字符上限、公式 8192 字符上限。 +7. **前端异步状态**:useEffect 依赖未变时不得清空已加载数据(同状态重复点击不能停在“加载中”);并发请求要有过期响应丢弃(active flag/AbortController);失败路径要有可见错误与重试。 +8. **测试真实性**:行为修复必须带“修复前失败、修复后通过”的回归测试;环境门禁(LibreOffice、PostgreSQL 并发、真实 AI)必须显式 skip 并注明原因,不得用 mock 通过冒充真实验收。 + +## Avoid reporting + +- 纯格式与风格问题(ruff / prettier 已在 CI 把关)。 +- 改动行为已被等价集成测试或 E2E 覆盖时,仅因缺少同名单测而报缺测试。 +- 没有具体并发路径的假想竞态(SQLite 单写者语义下的理论交错不算,除非改动显式依赖 PostgreSQL 行为)。 +- 对 `docs/` 报告类 HTML/Markdown 的措辞与结构建议——它们记录的是验收状态,不是产品代码。 diff --git a/.gitea/workflows/ai-review.yml b/.gitea/workflows/ai-review.yml new file mode 100644 index 0000000..641c494 --- /dev/null +++ b/.gitea/workflows/ai-review.yml @@ -0,0 +1,202 @@ +name: AI Review + +# 对抗式 PR 审查:先一遍找出候选缺陷,再对每一条派一个反驳者去证伪它,只有 +# 活下来的才会发到 PR 上。这个不对称是刻意的——AI 审查员死于误报远早于死于漏 +# 报,所以反驳阶段默认站在"这是误报"一边,"什么都没发现"是可接受的结果。 +# +# 引擎不在本仓库,在 vertai-tech/ai-review-engine。本文件只负责把三样东西摆到 +# 位再调它:被审查的代码、本仓库的审查侧重、引擎本身。 +# +# 接入一个仓库需要(各一次): +# 1. 引擎仓库的 templates/ai-review.yml -> 本仓库 .gitea/workflows/ai-review.yml +# 2. 引擎仓库的 templates/ai-review.md -> 本仓库 .gitea/ai-review.md,按本仓库 +# 的技术栈改写 +# 3. 下面这些组织级机密 / 变量(组织设置 → Actions)配好之后,新仓库就不用再 +# 单独配任何东西: +# secret REVIEW_API_KEY 模型网关的 key +# secret REVIEW_ENGINE_TOKEN 只读 PAT,用于拉私有的引擎仓库 +# variable REVIEW_BASE_URL 任意 OpenAI 格式的根地址 +# variable REVIEW_MODEL 支持 function calling 的模型 +# +# 自动修复默认是关的。要开,在**本仓库**的 设置 → Actions → 变量 里加一个(这两条 +# 都不要配到组织级,那样等于给所有仓库都开了),并在组织级配一次 +# REVIEW_FIX_MODEL: +# REVIEW_AUTOFIX=1 改代码、提交、推回 PR 分支 +# REVIEW_FIX_SUGGEST_ONLY=1 只把改法写进评论,一个字节都不落盘 +# 两个都配的时候,不动仓库的那个赢。fork 来的 PR 推不回去,apply 会自动降级成 +# 只给改法。 +# +# 默认只修一轮:修完推一个 commit,那次推送起的下一轮只审不改。要"一直修到没 +# 意见",把仓库变量 REVIEW_FIX_ROUNDS 调大——它数的是这个分支已经被自动修过几 +# 轮,轮次写在提交消息里。 +# +# 改法本身是两步定出来的:一个模型决定这条该不该由机器改、以及改完长什么样,另一 +# 个模型把它落成逐字命中的替换。想让这两步用不同的模型,就再配一个组织级的 +# REVIEW_FIX_PLAN_MODEL——判断那步值得用贵的,抄写那步不值得。不配也照样分两步, +# 只是两步都用 REVIEW_FIX_MODEL。 +# +# 成本由模型决定而不是由这个设计决定:同一个 PR 在便宜模型上是几分钱,在旗舰 +# 模型上是几美元。换模型前先看一眼那家的每 token 价格。 +# 换了模型记得同步组织变量 REVIEW_PRICES,缺单价的那轮评论不报金额。 +# +# 只发 COMMENT 类型的 review,从不 approve,也从不 request changes。所有发现集中 +# 在一条评论里按 1、2、3 编号,不再逐条钉成行内评论。 + +on: + pull_request: + # 没有 ready_for_review:Gitea Actions 不实现这个活动类型(它支持的是 + # opened / edited / closed / reopened / assigned / unassigned / + # synchronize / labeled / unlabeled / milestoned / demilestoned / + # review_requested / review_request_removed)。写上去只会让人以为有这个 + # 覆盖,实际没有。 + # + # 留下的缺口:草稿 PR 会被下面的 draft == false 挡掉,所以"取消草稿但不再 + # 推代码"不会触发任何东西。再推一次,或者用下面的 workflow_dispatch 手动 + # 指定 PR 号——那条路就是为这个场景留的。 + # + # 用 edited 能覆盖取消草稿,但它同时会在每次改标题、改描述时触发,而每一 + # 次都是一整轮 find + refute 的钱。为一个"再推一次就解决"的场景不值。 + types: [opened, synchronize, reopened] + workflow_dispatch: + inputs: + pr_number: + description: "PR number to review" + required: true + type: string + +# 连着推两次的 PR 只该被审一次——取消在飞的那轮,而不是花钱买一份关于旧代码 +# 的报告。 +concurrency: + group: ai-review-${{ github.event.pull_request.number || inputs.pr_number }} + cancel-in-progress: true + +jobs: + review: + # 草稿按定义就是没写完的东西;手动 dispatch 是明确的请求,所以绕过这条。 + # vars 那两个条件意味着本文件在网关配好之前合入也是安全的——job 直接跳过。 + if: >- + (github.event_name == 'workflow_dispatch' || github.event.pull_request.draft == false) + && vars.REVIEW_BASE_URL != '' + && vars.REVIEW_MODEL != '' + runs-on: ubuntu-latest + # 不设的话是 Gitea 默认的 6 小时,而下面的 continue-on-error 帮不上忙——它 + # 兜的是失败,不是挂起——于是一次卡在不返回的 API 调用上的审查会一直占着 + # runner,PR 的 check 一下午都是 pending。fantap-server 真踩过这个。 + # + # 60 分钟是照实测定的:一次阶段一实测 710 秒、29k 输出 token(其中 28.7k + # 是推理),阶段二还要在这之上再加最多八条反驳,开了自动修复的话阶段三再加 + # 最多八条的规划加执行。阶段三从每条一次调用变成每条两次(执行没对上文件还 + # 会重试)之后,原来的 45 分钟只在八条全中时才不够,但不够的表现是卡在超时 + # 上而不是干净报错,所以往上留一档。上面 concurrency 的 cancel-in-progress + # 负责拦住堆积。 + timeout-minutes: 60 + permissions: + # write 只有 REVIEW_AUTOFIX 那个模式才用得上——它要把改动推回 PR 分支。 + # 没开自动修复、或者只开了 REVIEW_FIX_SUGGEST_ONLY 的仓库,把这一行改回 + # read 就是了:只给改法的模式一个字节都不落盘,不需要写权限。留着 write + # 也没事,引擎只在 apply 模式下才提交任何东西。 + contents: write + pull-requests: write + steps: + # 一、被审查的代码。反驳阶段要读 PR head 上的整份文件,所以检出 head, + # 不是 merge commit。 + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha || format('refs/pull/{0}/head', inputs.pr_number) }} + + # 二、本仓库的审查侧重(.gitea/ai-review.md),取默认分支的那一份,不取 + # PR 分支的——否则一个 PR 可以先改掉审查重点,再让改过的重点来审自己。 + # || 'main' 是给 Gitea 兜底:它的 workflow_dispatch 载荷不总是带 + # github.event.repository。 + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.repository.default_branch || 'main' }} + sparse-checkout: .gitea + path: .review-profile + + # 三、引擎。跟着 v1 这个移动标签走:引擎修了 bug 移一次标签,所有接入的 + # 仓库同时生效。某个仓库被新版引擎坑到时,把这里单独钉到具体 commit 就能 + # 脱身,不用回滚全局。 + # + # 引擎仓库是私有的,Gitea 的 GITHUB_TOKEN 只对本仓库有效,拉不动它,所以 + # 要一个组织级的只读 PAT。continue-on-error 在这里是必要的:PAT 没配或者 + # 过期时,这一步失败不该把别人的 PR 弄红——下面那步会说明情况然后干净退出。 + - uses: actions/checkout@v4 + continue-on-error: true + with: + repository: vertai-tech/ai-review-engine + ref: v1 + path: .review-engine + token: ${{ secrets.REVIEW_ENGINE_TOKEN }} + + - name: Adversarial review + # 设计上就是建议性的:引擎自己的错误已经会干净退出,这里兜的是剩下的 + # (进程被杀、脚本没看见的 curl 失败)。审查机器人不该让人的 PR 变红。 + continue-on-error: true + # 比 job 的预算小一档,这样超时是记在这一步上,而不是整个 job 被砍—— + # 后者在界面上看不出是谁超的时。引擎内部 curl 的 max-time 比这再小一档 + # (1200s),所以正常顺序是那一层带着响应体和日志先报错,而不是这一层 + # 无声地杀掉进程。 + timeout-minutes: 55 + env: + REVIEW_API_KEY: ${{ secrets.REVIEW_API_KEY }} + REVIEW_BASE_URL: ${{ vars.REVIEW_BASE_URL }} + REVIEW_MODEL: ${{ vars.REVIEW_MODEL }} + # 可选,组织级变量。网关不在响应里报金额时(sub2api 就不报),评论按这张 + # 单价表算金额;不配就只报 token。格式见引擎 README「每轮花了多少」。 + REVIEW_PRICES: ${{ vars.REVIEW_PRICES }} + # 上游报过载 / 限流时每次重试前等几秒。不配就是 10,30,60,写 0 关掉。 + REVIEW_BUSY_RETRY_WAITS: ${{ vars.REVIEW_BUSY_RETRY_WAITS }} + # 评论里行号链接用的站点地址,如 https://git.sciark.cn。runner 在容器里访问 + # Gitea 时,API 返回的 html_url 和 GITHUB_SERVER_URL 都可能是容器内地址, + # 不配这个链接就打不开。组织级配一次。 + REVIEW_PUBLIC_URL: ${{ vars.REVIEW_PUBLIC_URL }} + # PR 号和仓库名经 env 进脚本,不直接拼进 run:手动 dispatch 的 pr_number + # 是任意字符串,表达式在 shell 解析之前就被展开,带引号就能注入命令。 + PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }} + REVIEW_REPO: ${{ github.repository }} + # 引擎的 Gitea REST helper 读这个。GITHUB_API_URL 由 act_runner 导出, + # 已经指向 /api/v1。 + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # 自动修复。REVIEW_AUTOFIX 没配或不是 1 的话,下面几个都没有意义。 + REVIEW_AUTOFIX: ${{ vars.REVIEW_AUTOFIX }} + REVIEW_FIX_SUGGEST_ONLY: ${{ vars.REVIEW_FIX_SUGGEST_ONLY }} + REVIEW_FIX_MODEL: ${{ vars.REVIEW_FIX_MODEL }} + # 一个分支最多自动修复几轮。不配就是 1:修一次,下一轮只审,跟从前 + # 一样。配大了才是"一直修到没意见",代价是往作者分支上推更多 commit、 + # 一个 PR 烧更多轮的钱。硬上限在引擎里。 + REVIEW_FIX_ROUNDS: ${{ vars.REVIEW_FIX_ROUNDS }} + # 规划用哪个模型。这不是开关——不配也照样先规划再执行,只是两步同一个 + # 模型。配了才是"贵的判断、便宜的抄写"那种分工。 + REVIEW_FIX_PLAN_MODEL: ${{ vars.REVIEW_FIX_PLAN_MODEL }} + # 只有当写修复的模型不在审查那个网关上时,才需要这两个。 + REVIEW_FIX_BASE_URL: ${{ vars.REVIEW_FIX_BASE_URL }} + REVIEW_FIX_API_KEY: ${{ secrets.REVIEW_FIX_API_KEY }} + # 同上,只有当规划的模型连修复那个网关都不在时才需要。 + REVIEW_FIX_PLAN_BASE_URL: ${{ vars.REVIEW_FIX_PLAN_BASE_URL }} + REVIEW_FIX_PLAN_API_KEY: ${{ secrets.REVIEW_FIX_PLAN_API_KEY }} + # 规划的思考强度。不配就是模型默认;写 high 之类的词按 reasoning_effort + # 发,写 JSON 对象就原样并进请求。各家写法见引擎 README。 + REVIEW_FIX_PLAN_THINKING: ${{ vars.REVIEW_FIX_PLAN_THINKING }} + # 对照用的第二个执行模型。只在直接改的模式下跑,改动不落盘,只和 + # REVIEW_FIX_MODEL 的改动一起贴进评论,方便比较哪个模型抄得好。 + REVIEW_FIX_MODEL2: ${{ vars.REVIEW_FIX_MODEL2 }} + REVIEW_FIX_MODEL2_BASE_URL: ${{ vars.REVIEW_FIX_MODEL2_BASE_URL }} + REVIEW_FIX_MODEL2_API_KEY: ${{ secrets.REVIEW_FIX_MODEL2_API_KEY }} + # 只有当上面那个自动 token 推不动分支时,才需要这个写权限 PAT。 + REVIEW_FIX_TOKEN: ${{ secrets.REVIEW_FIX_TOKEN }} + run: | + ENGINE=.review-engine/scripts/ai_review.py + if [ ! -f "$ENGINE" ]; then + # 拉引擎那步失败了。说清楚原因,否则这里的沉默会被读成"没发现问题"。 + echo "::warning::引擎没拉下来(多半是组织级 secret REVIEW_ENGINE_TOKEN 没配或已过期),本轮未审查。" + exit 0 + fi + + case "$PR_NUMBER" in + ''|*[!0-9]*) + echo "::warning::PR 号必须是数字,收到的是:$PR_NUMBER。本轮未审查。" + exit 0 ;; + esac + python3 "$ENGINE" --repo "$REVIEW_REPO" --pr "$PR_NUMBER" + diff --git a/.github/workflows/ci.yml b/.gitea/workflows/ci.yml similarity index 61% rename from .github/workflows/ci.yml rename to .gitea/workflows/ci.yml index 14f3cb7..da06626 100644 --- a/.github/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -16,7 +16,9 @@ jobs: --health-cmd pg_isready --health-interval 5s --health-timeout 5s --health-retries 10 env: - DATABASE_URL: postgresql://postgres:ci-only-password@localhost:5432/traceiq + # Gitea act_runner 的 job 跑在容器里,与服务容器同网络:主机名必须用服务名 + # postgres(GitHub 托管 runner 的 localhost 语义在这里连不通,勿改回)。 + DATABASE_URL: postgresql://postgres:ci-only-password@postgres:5432/traceiq E2E_PASSWORD: CI-Test-Only-SecurePassword882! PYTHONPATH: . steps: @@ -35,8 +37,24 @@ jobs: - run: uv run python manage.py makemigrations --check --dry-run - run: uv run pytest --cov=finance --cov-report=term-missing --cov-fail-under=85 - run: npm run build - - run: npm audit --omit=dev --registry=https://registry.npmjs.org - - run: uv run pip-audit + # 审计端点在 runner 网络下偶发 TLS 断开(npm error audit endpoint returned + # an error),重试区分网络抖动与真实漏洞:漏洞是确定性的,重试后照样红。 + - name: npm audit (production deps) + run: | + for attempt in 1 2 3; do + npm audit --omit=dev --registry=https://registry.npmjs.org && exit 0 + echo "::warning::npm audit 第 ${attempt} 次失败,15 秒后重试" + sleep 15 + done + exit 1 + - name: pip-audit + run: | + for attempt in 1 2 3; do + uv run pip-audit && exit 0 + echo "::warning::pip-audit 第 ${attempt} 次失败,15 秒后重试" + sleep 15 + done + exit 1 - run: uv run python manage.py migrate --noinput - run: uv run python manage.py collectstatic --noinput - run: uv run python manage.py bootstrap --username admin --organization CI @@ -53,8 +71,11 @@ jobs: trap 'kill "$web" "$worker"' EXIT for i in {1..30}; do curl -fsS http://127.0.0.1:8000/api/health && break; sleep 1; done npm run test:e2e + # Gitea 不支持 upload-artifact@v4 的 artifact 协议(GHESNotSupportedError), + # 上传失败不该把已经全绿的测试标红;迁到 GitHub 托管时此步骤自动生效。 - uses: actions/upload-artifact@v4 if: always() + continue-on-error: true with: name: browser-results path: | diff --git a/README.md b/README.md index 7975490..25e1afa 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,14 @@ 需要 Node.js 22、uv、Python 3.12、PostgreSQL 17。所有示例从仓库根目录执行。 +快速启动本地页面(迁移、管理员初始化、前端构建、API 与导入 worker 一条命令完成,健康检查通过后自动打开 `http://127.0.0.1:8000`;`Ctrl-C` 或 `scripts/dev_up.sh --stop` 停止,`--no-open` 跳过自动打开浏览器): + +```bash +scripts/dev_up.sh +``` + +未设置 `DATABASE_URL` 时该脚本使用 SQLite 轻量开发库 `var/db.sqlite3`,仅限页面测试与开发回归;金额聚合与财务验收需按下方配置 PostgreSQL。手动分步流程如下: + ```bash uv sync --frozen --python 3.12 npm ci --registry=https://registry.npmjs.org @@ -99,7 +107,7 @@ npm audit --omit=dev --registry=https://registry.npmjs.org uv run pip-audit ``` -Playwright 需要正在运行的本地 API 和 worker,可用 E2E_PASSWORD 指定测试 admin 密码,默认读取本地凭据文件。首次执行 `npx playwright install chromium`。浏览器 fixture 测试会新建 `e2e-finance.csv` 数据集,不可指向正式生产环境。`.github/workflows/ci.yml` 在独立 PostgreSQL 上执行同样流程,尚未推送到远端触发。 +Playwright 需要正在运行的本地 API 和 worker,可用 E2E_PASSWORD 指定测试 admin 密码,默认读取本地凭据文件。首次执行 `npx playwright install chromium`。浏览器 fixture 测试会新建 `e2e-finance.csv` 数据集,不可指向正式生产环境。`.gitea/workflows/ci.yml` 在独立 PostgreSQL 服务容器上执行同样流程(Gitea act_runner 的 job 跑在容器内,服务主机名用服务名 `postgres` 而非 localhost)。`.gitea/workflows/ai-review.yml` 对每个 PR 运行对抗式 AI 审查与自动修复,引擎在 vertai-tech/ai-review-engine(v1 标签),本仓库审查侧重见 `.gitea/ai-review.md`。 原附件只在本机使用,不在仓库中: diff --git a/scripts/dev_up.sh b/scripts/dev_up.sh new file mode 100755 index 0000000..f8d763d --- /dev/null +++ b/scripts/dev_up.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# 一键启动本地开发环境:迁移 → 初始化管理员 → 构建前端 → 启动 API+worker → 打开页面。 +# 默认使用 SQLite 轻量开发库(var/db.sqlite3);设置 DATABASE_URL 则使用 PostgreSQL。 +# Ctrl-C 或 --stop 停止本脚本启动的进程。 +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +PORT="${PORT:-8000}" +SERVER_PID_FILE="var/dev-server.pid" +WORKER_PID_FILE="var/dev-worker.pid" +SERVER_LOG="var/dev-server.log" +WORKER_LOG="var/dev-worker.log" + +die() { echo "错误:$*" >&2; exit 1; } + +detect_python() { + if [ -x ".venv/bin/python" ]; then + PY=".venv/bin/python" + elif command -v uv >/dev/null 2>&1; then + PY="uv run python" + else + die "未找到 .venv/bin/python 或 uv,请先安装依赖(uv sync)" + fi +} + +read_pidfile() { [ -f "$1" ] && cat "$1" || true; } + +stop_all() { + local stopped=0 pid + for file in "$SERVER_PID_FILE" "$WORKER_PID_FILE"; do + pid="$(read_pidfile "$file")" + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + kill "$pid" 2>/dev/null || true + stopped=1 + fi + rm -f "$file" + done + [ "$stopped" = 1 ] && echo "已停止本地开发服务" || echo "没有正在运行的开发服务" +} + +health_ok() { curl -fsS --max-time 2 "http://127.0.0.1:$PORT/api/health" >/dev/null 2>&1; } + +open_page() { + local url="http://127.0.0.1:$PORT/" + if command -v open >/dev/null 2>&1; then open "$url" + elif command -v xdg-open >/dev/null 2>&1; then xdg-open "$url" >/dev/null 2>&1 || true + fi +} + +case "${1:-}" in + --stop) + stop_all + exit 0 + ;; + "" | --no-open) ;; + *) die "未知参数:$1(支持 --stop / --no-open)" ;; +esac + +detect_python +mkdir -p var + +if health_ok; then + echo "已在 $PORT 端口运行,直接打开页面" + [ "${1:-}" != "--no-open" ] && open_page + exit 0 +fi + +if [ -z "${DATABASE_URL:-}" ]; then + echo "未设置 DATABASE_URL,使用 SQLite 轻量开发库 var/db.sqlite3(仅限页面测试与开发回归)" +fi + +echo "执行数据库迁移…" +$PY manage.py migrate --no-input >/dev/null + +if [ ! -f var/local-access.txt ]; then + echo "初始化本地管理员…" + PYTHONPATH="$ROOT" $PY scripts/init_local.py +fi + +if [ ! -d frontend/dist ]; then + echo "构建前端(首次)…" + npm run build >/dev/null +fi + +echo "启动 worker(日志 ${WORKER_LOG})…" +nohup $PY manage.py import_worker >"$WORKER_LOG" 2>&1 & +echo $! >"$WORKER_PID_FILE" + +echo "启动 API 服务…" +nohup $PY manage.py runserver "127.0.0.1:$PORT" --noreload >"$SERVER_LOG" 2>&1 & +echo $! >"$SERVER_PID_FILE" + +cleanup() { + stop_all >/dev/null 2>&1 || true +} +trap cleanup INT TERM + +for _ in $(seq 1 30); do + if health_ok; then + break + fi + if [ -n "$(read_pidfile "$SERVER_PID_FILE")" ] && ! kill -0 "$(read_pidfile "$SERVER_PID_FILE")" 2>/dev/null; then + tail -20 "$SERVER_LOG" >&2 + die "API 服务启动失败(完整日志:${SERVER_LOG})" + fi + sleep 1 +done +health_ok || { tail -20 "$SERVER_LOG" >&2; die "健康检查超时(日志:${SERVER_LOG})"; } + +echo +echo "本地开发环境已就绪:http://127.0.0.1:$PORT" +[ -f var/local-access.txt ] && echo "登录凭据:var/local-access.txt(用户名 admin,权限 0600)" +if [ -z "${DATABASE_URL:-}" ]; then + echo "提示:当前为 SQLite 轻量模式;高精度金额聚合与生产验收请按 README 配置 PostgreSQL。" +fi +echo "日志:${SERVER_LOG} / ${WORKER_LOG} · Ctrl-C 或 scripts/dev_up.sh --stop 停止" +echo + +[ "${1:-}" != "--no-open" ] && open_page + +wait "$(read_pidfile "$SERVER_PID_FILE")" || true +cleanup