Compare commits
9
Commits
05168f671e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05f2ee416e | ||
|
|
5e1437679b | ||
|
|
fa047ed3ee | ||
|
|
f9f9ad751d | ||
|
|
e90e37b77e | ||
|
|
e337d9c989 | ||
|
|
35789379ab | ||
|
|
a3d91ad17f | ||
|
|
0739bcc4fb |
@@ -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 的措辞与结构建议——它们记录的是验收状态,不是产品代码。
|
||||
@@ -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 导出,
|
||||
# 已经指向 <instance>/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"
|
||||
|
||||
@@ -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: |
|
||||
@@ -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`。
|
||||
|
||||
原附件只在本机使用,不在仓库中:
|
||||
|
||||
|
||||
Executable
+123
@@ -0,0 +1,123 @@
|
||||
#!/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
|
||||
|
||||
# init_local.py 以数据库为准幂等执行:缺 admin 时创建并写凭据文件,已存在则跳过
|
||||
echo "初始化本地管理员(已存在则跳过)…"
|
||||
PYTHONPATH="$ROOT" $PY scripts/init_local.py
|
||||
|
||||
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
|
||||
@@ -3,7 +3,7 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
from django.core.management import call_command
|
||||
|
||||
from finance.models import FormulaExport
|
||||
from finance.models import Dataset, FormulaExport
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -17,6 +17,10 @@ def test_worker_processes_queued_import_and_reports_export_failure(workspace):
|
||||
) as process_import:
|
||||
call_command("import_worker", once=True)
|
||||
process_import.assert_called_once_with(ds.pk)
|
||||
# PostgreSQL 上 worker 持咨询锁会立即重认领 processing 任务:模拟导入完成,
|
||||
# 否则第二次 --once 又被它抢占,轮不到下面的导出任务(SQLite 无咨询锁,
|
||||
# 不走立即恢复路径,两种后端行为一致)。
|
||||
Dataset.objects.filter(pk=ds.pk).update(status="ready")
|
||||
|
||||
export = FormulaExport.objects.create(dataset=ds, user=ds.created_by, context={})
|
||||
with patch(
|
||||
|
||||
Reference in New Issue
Block a user