Node.js / Express REST API powering the Flutter app tree-project-frontend.
Handles authentication, tree-survey CRUD, project/area management, image
storage (Cloudinary), reverse-proxying to a Python ML service, an AI chat
assistant (Text-to-SQL) and a tool-using AI Agent.
Production runs on a self-hosted Ubuntu host behind a reverse proxy
(Nginx; the original deployment used Tailscale for private networking), with
PM2 cluster-mode supervision (see ecosystem.config.js).
Documentation hub (architecture, API, deploy, workflow):
frontend/docs/README.md → ONBOARDING_READING_PATH.md
Backend catalog (this repo): docs/README.md → docs/SOURCE_LAYOUT.md
flowchart TB
app["Flutter app<br/>sustainable_treeai (Android / iOS)"]
subgraph backendHost["Backend host (Ubuntu)"]
nbe["Nginx<br/>:443 → Node :3000<br/>(Tailscale Funnel exposes :443 publicly)"]
be["backend (this repo)<br/>Node 20 + Express<br/>PM2 cluster ×2 · ecosystem.config.js"]
nbe --> be
end
subgraph mlHost["ML host (separate machine, private network)"]
nml["Nginx<br/>:443 → uvicorn :8100"]
ml["ml_service/ (FastAPI)<br/>Depth Anything v3 metric-large<br/>OpenVINO FP16 on Intel NPU<br/>+ server YOLOv8m-seg on Intel iGPU<br/>+ pure pinhole DBH calculator"]
nml --> ml
end
ext[("PostgreSQL 15+ · Cloudinary · PlantNet / GBIF /<br/>iNaturalist · OpenAI / Gemini / SiliconFlow / Anthropic")]
app -- "HTTPS (TLS)" --> nbe
app -- "HTTPS + JWT<br/>multipart image / EXIF / bbox" --> nbe
be -- "multipart + X-ML-API-Key" --> nml
nml -- "DBH / mask / warnings" --> be
be --> ext
gh["GitHub push (main)"] -- "HMAC-SHA256" --> nbe
be -. triggers .-> deploy["scripts/deploy.sh<br/>git pull → npm install → migrate<br/>→ pm2 reload tree-backend → /health → auto-rollback"]
Two-service deployment with a Node ML proxy. The Flutter app talks to
the Node backend for CRUD/auth/AI and for static DBH measurement through
/api/ml-service/*. The backend then forwards multipart images to the
FastAPI ml_service and injects X-ML-API-Key from the host environment,
so the ML API key is not stored on the client. Legacy or experimental live
scan paths may still talk to the FastAPI host directly, but the production
DBH HTTP flow is Flutter → Node proxy → FastAPI.
The exact public hostnames, private-network IPs and reverse-proxy domains are deployment-specific and not committed to this repo. Configure them via environment variables (
BASE_URL,ML_SERVICE_URL,ML_SERVICE_PUBLIC_URL,CORS_ALLOWED_ORIGINS) on the host.
Each subsection traces one user-visible feature from the Flutter app down to the database / external services. Bracketed paths are files in this repo.
POST /api/login)flowchart TB
fp["Flutter LoginPage<br/>{account, password, loginType}"]
rl["middleware/loginRateLimiter<br/>10 req / 15 min / IP"]
bl["middleware/ipBlacklist"]
rt["routes/users.js POST /login<br/>WHERE username=account (+role check if admin)<br/>bcrypt.compare(password, users.password_hash)"]
db[("PostgreSQL · users<br/>(user_id, username, display_name, role, …)")]
jwt["JWT (HS256, 24 h)<br/>payload = {user_id, username, role}"]
resp["{token, user, mlConfig?}<br/>mlConfig.url only — no client ML API key"]
st["flutter_secure_storage (auth_jwt_token, user_info)<br/>SharedPreferences (ml_service_url, ml_api_key)"]
fp --> rl --> bl --> rt --> db --> jwt --> resp --> st
Logout is client-side only (delete token + clear ML credentials).
flowchart TB
fm["Flutter survey form (V2 / V3)<br/>multipart/form-data: fields + photo[]"]
jw["middleware/jwtAuth → req.user"]
up["middleware/upload (multer)<br/>memoryStorage · 10 MB · image/* only"]
rt["routes/treeSurvey.js POST /api/tree_survey/create_v2<br/>(photos via routes/tree_images.js POST /api/tree-images/upload)"]
cl["Cloudinary upload_stream(buffer)<br/>→ tree_images (cloud_url, thumbnail_url, public_id)"]
geo["resolveCountyByLngLat(lng, lat)<br/>utils/geo.js + data/tw_county.geojson<br/>(22 counties, MOI 1140318)"]
db[("PostgreSQL · tree_survey + tree_images<br/>single transaction")]
out["{ tree, photos[] }"]
fm --> jw --> up --> rt
rt --> cl
rt --> geo
rt --> db --> out
GET / PUT / DELETE follow the same auth + ownership-check pattern in routes/treeSurvey.js (tree records) and routes/tree_images.js (photos).
POST /api/chat)flowchart TB
cp["Flutter ChatPage<br/>POST /api/chat {message, model_preference, sessionId, projectAreas}"]
jw["jwtAuth + requireRole(調查管理員) + aiLimiter"]
rt["routes/ai.js"]
sql["gpt-4.1-nano drafts SQL<br/>(or NOT_A_DATA_QUERY → knowledge mode)"]
sv["services/sqlQueryService.executeSecureQuery<br/>security validation + 1 auto-retry · read-only"]
db[("PostgreSQL")]
exp["explain result with selected model<br/>(gpt-5-nano/mini/5.1, gemini-*, Qwen/*,<br/>deepseek-ai/DeepSeek-V3 default)"]
log[("chat_logs<br/>(user_id, message, response, model_used, session_id)")]
out["JSON response"]
cp --> jw --> rt --> sql --> sv --> db --> exp --> out
exp --> log
Response is a regular JSON payload (not SSE). Audit trail: every Q/A pair +
model + final response is written to chat_logs (see
routes/ai.js).
/api/agent/*)flowchart TB
ap["Flutter AgentPage<br/>POST /api/agent/chat {messages}"]
rt["routes/agent.js"]
svc["services/agentService.js<br/>tool-using loop · default model gpt-5.4-mini"]
t1["fetch_allowed_url(s) / search_public_documents<br/>(allow-listed policy URLs)"]
t2["list_policy_sources / list_allowed_domains / list_demo_policy_urls"]
t3["export_excel / export_pdf / export_ai_report"]
impl["services/agentDataTools.js (tool implementations)"]
llm["LLM: prefers SiliconFlow if keys configured,<br/>otherwise OpenAI; cross-provider fallback"]
out["JSON {answer, tool trace}"]
ap --> rt --> svc
svc --> t1 --> impl
svc --> t2 --> impl
svc --> t3 --> impl
svc --> llm --> out
flowchart TB
fp["Flutter measurement page (V3)<br/>image + EXIF + phone bbox<br/>Authorization: Bearer JWT"]
proxy["backend routes/ml_service.js<br/>POST /api/ml-service/auto-measure-dbh<br/>multer memory upload · JWT protected"]
app2["ml_service/app.py<br/>verify_api_key (hmac.compare_digest)<br/>RateLimitMiddleware (ML_RATE_LIMIT, default 30/hr/IP)"]
seg["ServerYOLO<br/>YOLOv8m-seg OpenVINO<br/>Intel iGPU · 832 px<br/>→ trunk mask"]
de["depth_estimation.py<br/>Depth Anything v3 metric-large<br/>OpenVINO FP16 · Intel NPU · 504×378<br/>→ monocular metric depth"]
dbh["dbh_calculator.py<br/>mask row chord width Wpx<br/>central 1/3 chord-depth z<br/>pure pinhole d = z · Wpx/fpx"]
out["{ dbh_cm, trunk_depth_m, trunk_pixel_width,<br/>quality_code, mask/debug images, confidence }"]
ui["Flutter form/scanner<br/>user confirms → tree_survey / pending measurement"]
fp --> proxy --> app2
app2 --> seg --> dbh
app2 --> de --> dbh
dbh --> out --> ui
The production static DBH flow goes through the Node proxy. WebSocket /ws/scan is kept for live preview experiments and should be treated as
optional unless it is routed through the same authentication path.
/api/species/*)flowchart TB
sp["Flutter SpeciesIdentifyPage<br/>POST /api/species/identify (image)"]
rt["routes/speciesIdentification.js"]
svc["services/speciesIdentificationService"]
pn["PlantNet REST API<br/>(org=tree, lang=zh-tw)"]
add["autoAddSpeciesFromIdentification()<br/>insert tree_species (scientific_name, …)<br/>insert common_names → species_synonyms"]
out["{ candidates[],<br/>top: {species_id, scientific_name,<br/>common_name, score} }"]
sp --> rt --> svc
svc --> pn --> svc
svc --> add
svc --> out
V3 form auto-creates a species row at submit time (services/speciesIdentificationService.js) so the user never has to "save species" manually.
flowchart TB
cl["client<br/>GET /api/project_areas/county_by_coords?lng=&lat="]
rt["routes/project_areas.js"]
geo["utils/geo.js · resolveCountyByLngLat<br/>data/tw_county.geojson (cached on first call)<br/>bbox prefilter → booleanPointInPolygon (@turf/turf)"]
out["{ name: '嘉義縣', code: '10010',<br/>eng: 'Chiayi County' } or null"]
cl --> rt --> geo --> out
scripts/backfill_county.js --apply re-runs the helper for every
projects.area row whose county is NULL.
flowchart TB
gh["GitHub push to main<br/>POST https://<public-host>/webhook/deploy<br/>X-Hub-Signature-256: sha256=…"]
wh["routes/webhook.js<br/>hmac(DEPLOY_WEBHOOK_SECRET) timing-safe"]
sp["spawn bash scripts/deploy.sh<br/>(timeout 120 s, log → logs/deploy.log)"]
s1["/health check<br/>save SHA → .last_good_commit"]
s2["git pull origin main"]
s3["npm install --production"]
s4["run pending migrations<br/>(skip with --skip-migrate)"]
s5["pm2 reload ecosystem.config.js<br/>(zero-downtime)"]
s6{"curl /health"}
ok(["done"])
rb["git reset --hard $LAST_GOOD<br/>pm2 reload · alert"]
gh --> wh --> sp --> s1 --> s2 --> s3 --> s4 --> s5 --> s6
s6 -- pass --> ok
s6 -- fail --> rb
GET /webhook/status (Bearer ADMIN_API_TOKEN) returns the tail of
logs/deploy.log.
pg + pg-format + pg-copy-streams)jsonwebtoken, HS256, fixed algorithm)openai, @anthropic-ai/sdk, @google/generative-ai@turf/turf for point-in-polygon (county auto-detection, project boundaries)ecosystem.config.js)cd backend
cp tree-app-backend-prod.env .env # then edit values (see Configuration)
npm install
node scripts/migrate.js # fresh empty DB: schema only (+ optional dev CSV)
node scripts/seed_dev_users.js # dev/CI only: test accounts (NOT for production)
npm run dev # nodemon on PORT (default 3000)
Health check: GET /health → 200 OK (no auth required).
In production mode, app.js runs scripts/run_pending_migrations.js
automatically on startup (schema only; no CSV import). For a fresh empty DB,
run node scripts/migrate.js manually once.
All configuration is via environment variables loaded with dotenv from
.env. The keys actually read by the code:
| Key | Default | Notes |
|---|---|---|
NODE_ENV | — | When production: required env-vars enforced, migrations auto-run, error responses are sanitized, model whitelist enforced for AI chat |
PORT | 3000 | Express listen port |
BASE_URL | http://localhost:${PORT} | Used to build full URLs for AI chat Excel-export download links |
config/db.js)| Key | Notes |
|---|---|
DATABASE_URL | PostgreSQL connection string. Required in production |
DB_HOST / DB_USER / DB_PASSWORD / DB_NAME / DB_PORT | Discrete connection params (fallback when DATABASE_URL is unset). First admin: node scripts/create_lab_admin.js (production) or seed_dev_users.js (dev/CI only) |
DB_SSL_REJECT_UNAUTHORIZED | true to enforce TLS cert verification; default false (typical for Render-style providers) |
middleware/jwtAuth.js)| Key | Notes |
|---|---|
JWT_SECRET | Required in production. HS256 only — algorithm-confusion attack is blocked |
JWT_EXPIRES_IN | Default 24h |
app.js)| Key | Notes |
|---|---|
CORS_ALLOWED_ORIGINS (or CORS_ORIGIN) | Comma-separated whitelist. In production, an empty list = deny all cross-origin. Same-origin and missing Origin header (mobile apps) are always allowed |
config/cloudinary.js)| Key | Notes |
|---|---|
CLOUDINARY_CLOUD_NAME / CLOUDINARY_API_KEY / CLOUDINARY_API_SECRET | All three needed for upload to be active. If unset, image upload is disabled (the code returns a clear warning instead of crashing) |
routes/ml_service.js, routes/users.js)| Key | Notes |
|---|---|
ML_SERVICE_URL | Internal URL of the Python FastAPI service (e.g. http://127.0.0.1:8100) |
ML_SERVICE_PUBLIC_URL | Optional. Public URL returned to the client by /login; falls back to ML_SERVICE_URL |
ML_API_KEY | Sent as X-ML-API-Key to the FastAPI service |
| Key | Used by | Notes |
|---|---|---|
GEMINI_API_KEY | services/geminiService.js | Google Gemini (chat + report generation) |
OPENAI_API_KEY | services/openaiService.js, routes/ai.js, routes/admin.js | OpenAI |
Claude_API_KEY | routes/ai.js, routes/admin.js, scripts/generate_species_knowledge.js | Anthropic |
SiliconFlow_API_KEY, Alt1_SiliconFlow_API_KEY, Alt2_…, Alt3_… | services/agentService.js | SiliconFlow (free tier). Up to 4 keys are loaded; agent rotates to the next when one is exhausted |
PLANTNET_API_KEY | services/speciesIdentificationService.js, routes/speciesIdentification.js | PlantNet (image-based species ID) |
routes/webhook.js)| Key | Notes |
|---|---|
DEPLOY_WEBHOOK_SECRET | HMAC-SHA256 secret. Validated against X-Hub-Signature-256 using a timing-safe compare |
ADMIN_API_TOKEN | Token to read GET /webhook/status (deploy log tail). The only place a static token is still accepted |
| Key | Notes |
|---|---|
DISABLE_IP_GUARD | true to disable the IP-blacklist middleware (testing only) |
TEST_BASE_URL, TEST_ADMIN_USER, TEST_ADMIN_PASS, TEST_SURVEY_USER, TEST_SURVEY_PASS | Used by tests/regression.test.js and tests/apiIntegration.test.js |
package.json:
| Script | Command |
|---|---|
npm start | node app.js |
npm run dev | nodemon app.js |
npm test | Intent classification + SQL validation tests |
npm run test:intent | Intent classifier only |
npm run test:sql | SQL validation only |
npm run test:integration | Chat integration |
npm run test:api | API integration |
| `npm run test:regression[:local | :verbose]` |
npm run test:all | Regression + API + security audit |
The tests under tests/ are plain Node scripts, not a framework.
scripts/:
| File | Purpose |
|---|---|
migrate.js | Reads schema files in database/initial_data/ in a fixed order, executes them in one transaction-friendly sequence, COPYs tree_survey_data.csv (with on-the-fly date sanitization), creates views, and resets the tree_survey PK sequence. Idempotent — every migration uses IF NOT EXISTS / CREATE OR REPLACE / safe upserts |
deploy.sh | Production auto-deploy: saves rollback point, git pull, npm install --production, runs migrations (unless --skip-migrate), PM2 graceful reload, health-check; auto-rollback on failure |
rollback.sh | Manual rollback to .last_good_commit |
backup_db.sh | pg_dump wrapper for the nightly cron |
health_check.sh | Curl /health, used by external monitoring |
populateSpeciesRegionScore.js | Computes per-region species suitability scores. Run inside migrate.js automatically |
enrich_species_synonyms.js | LLM-assisted synonym expansion (deprecated — superseded by Text-to-SQL) |
generate_species_knowledge.js, populate_knowledge.js, populate_knowledge_from_survey.js, generateEmbeddings.js | Knowledge-base / RAG seed scripts. Not part of the standard pipeline since the 2025-11 switch to Text-to-SQL; kept for the legacy embedding table |
migrate_placeholder_fix.js | One-off historical cleanup; safe to re-run |
backend/
├── app.js # Entry. Trust proxy → CORS → helmet → JSON
│ # → /health → /webhook → /api(ipBlacklist→
│ # burstLimiter→apiLimiter→jwtAuth→router)
│ # → global error handler. Hourly cleanup
│ # cron and one-shot 5s-after-startup
│ # chat-log cleanup are also kicked off here.
├── config/
│ ├── db.js # pg.Pool (DATABASE_URL, optional SSL)
│ ├── cloudinary.js # SDK config + isCloudinaryConfigured guard
│ └── apiKeys.js # Static helper for AI key lookup
├── controllers/ # Thin handlers called from routes/
│ ├── treeSurveyCreateController.js / *Update / *Batch
│ ├── treeManagementController.js
│ ├── carbonSinkController.js
│ ├── csvImportController.js
│ ├── reportController.js / aiReportController.js
│ ├── openaiController.js
│ ├── aiController.js
│ └── knowledgeController.js
├── middleware/
│ ├── jwtAuth.js # HS256, Bearer-only, OPTIONS + /login skip
│ ├── rateLimiter.js # apiLimiter / burstLimiter / loginLimiter / aiLimiter
│ ├── ipBlacklistGuard.js # Reads ip_blacklist row; auto-expires
│ ├── loginAttemptMonitor.js # 5-strikes per account, 30-min lock
│ ├── projectAuth.js # Per-resource project-permission check (5-min cache)
│ └── roleAuth.js # 5-tier RBAC (see "Auth model")
├── routes/ # 24 modules — see "API surface"
├── services/
│ ├── sqlQueryService.js # Text-to-SQL: intent classifier, schema
│ │ # info, SQL safety validator (forbidden
│ │ # keywords/patterns, complexity guard,
│ │ # whitelist), result formatter
│ ├── agentService.js # ReAct Agent: tool definitions, multi-key
│ │ # rotation, persistent token budget table,
│ │ # max 8 tool steps
│ ├── geminiService.js # Gemini chat
│ ├── openaiService.js # OpenAI chat (used by controllers)
│ ├── speciesIdentificationService.js # PlantNet wrapper + GBIF / iNaturalist enrichment
│ ├── speciesSynonymService.js # Synonym table + scheduled maintenance
│ ├── knowledgeEmbeddingService.js # pgvector cosine search (legacy RAG)
│ ├── auditLogService.js # Centralized audit-log writer
│ └── ipBlacklistService.js # recordOffense / cleanupOldLoginAttempts
├── utils/
│ ├── cleanup.js # Hourly maintenance (see "Background jobs")
│ └── …
├── database/
│ └── initial_data/ # Schema + seed + post-schema fix-up SQL
│ # (see "Database")
├── ml_service/ # Standalone Python FastAPI service
├── scripts/ # Operational scripts (above)
├── tests/ # Plain Node tests
├── docs/ # Internal design notes
├── ecosystem.config.js # PM2 config (cluster, 2 workers)
└── tree-app-backend-prod.env # Sample production env-file (do not commit secrets)
trust proxy (Tailscale / Nginx)
│
▼
CORS — whitelist from CORS_ALLOWED_ORIGINS (empty in prod = deny)
│
▼
helmet — standard security headers
│
▼
express.json (limit 10 MB; rawBody captured for /webhook routes)
│
├─► GET /health (no auth)
│
├─► POST /webhook/deploy (HMAC-SHA256 verified)
├─► GET /webhook/status (X-Admin-Token)
│
└─► /api/*
│
▼
ipBlacklistGuard
│ (reads ip_blacklist; auto-expires; can be disabled with
│ DISABLE_IP_GUARD=true for local testing)
▼
burstLimiter — 20 req / 10 s per IP. Excess writes ip_blacklist
▼ with offense-count escalation (5-min base ban)
apiLimiter — 500 req / 15 min per IP
▼
jwtAuth — Bearer HS256; OPTIONS and /login are skipped
▼
Router — see "API surface"
│
▼
global error handler — logs full stack; in prod returns "伺服器發生未預期的錯誤"
JWT-only authentication (the historical X-Admin-Token fallback was removed
on 2026-04-27). Token payload (set in routes/users.js after login) includes
user_id, username, role, and is signed HS256.
middleware/roleAuth.js)| Level | Role | Powers |
|---|---|---|
| 5 | 系統管理員 | Backup/restore, user mgmt, all projects, IP blacklist mgmt |
| 4 | 業務管理員 | User mgmt, project create/delete, all projects' data |
| 3 | 專案管理員 | Boundaries, area CRUD, delete trees on owned projects |
| 2 | 調查管理員 | Add/edit/retire/restore trees, import/export, AI chat & agent (own projects) |
| 1 | 一般使用者 | Read-only on own projects |
requireRole('業務管理員') admits anyone with a role of equal or higher
level. req.isAdmin is set when level ≥ 3.
Two middlewares in middleware/projectAuth.js:
projectAuth — for write/delete on a single resource. Looks up
user_projects (level ≥ 4 always passes; deeper levels checked against the
junction table). For PUT/DELETE without a body project_code, the
middleware first reads the resource's project_code from tree_survey.projectAuthFilter — for list endpoints. Sets req.projectFilter to
either null (no restriction) or an array of project codes the user can
see; routes apply it as WHERE project_code = ANY($1::text[]).A 5-minute in-memory cache (_cache in projectAuth.js) holds each user's
project list to avoid hitting the DB on every request.
invalidateUserProjectsCache(userId) is called after permission edits.
loginAttemptMonitor.js).loginLimiter: 50 attempts / hour per IP.burstLimiter: 20 req / 10 s per IP. Triggers escalating ban writes to
ip_blacklist (recordOffense).LOGIN_FAILED and LOGIN events go to audit_logs via auditLogService.All endpoints below are mounted under /api. They require a valid JWT, with
an additional minimum role where indicated.
routes/users.js)POST /login — accepts { account, password, loginType: 'admin' | … }. Admin
login is restricted to roles 系統管理員 / 業務管理員 / 專案管理員 / 調查管理員.
Returns JWT + accessible projects + ML service config (URL + API key).GET /users (≥業務管理員)POST /users (≥業務管理員)PUT /users/:id (≥業務管理員)PUT /users/:id/status (≥業務管理員) — enable/disableDELETE /users/:id (≥業務管理員)GET /users/:userId/projects / PUT /users/:userId/projects (≥業務管理員)POST /register — public invite-code registration (loginRateLimiter).
Consumes an invite code; if the code has requires_approval, the new account
is created with is_active=false / pending_approval=true for admin review.GET /invites / POST /invites (≥業務管理員) — list / create invite codes
(role ≤ creator, max_uses, expires_in_days, project_codes[]).PATCH /invites/:inviteId/deactivate (≥業務管理員) — disable a code.DELETE /invites/:inviteId (≥業務管理員) — delete (writes audit DELETE_INVITE).
See ADMIN_AND_INVITE_DESIGN.md for the full design.routes/projects.js, routes/project_areas.js, routes/project_boundaries.js)GET /projects / /projects/by_area/:area / /projects/by_name/:name /
/projects/by_code/:code — filtered by projectAuthFilter. Each prefers
the projects table and falls back to SELECT DISTINCT FROM tree_survey
if the row is missing (legacy data path).POST /projects/add (≥業務管理員) — resolves area_id by area name lookup.DELETE /projects/:code (≥業務管理員)GET/POST/PUT/DELETE /project_areas (POST/PUT/DELETE need ≥專案管理員)POST /project_areas/cleanup (≥系統管理員) — manual run of the same routine
that the hourly cron triggers./project-boundaries: GeoJSON storage. Public reads, writes need ≥專案管理員.
Includes POST /check, /find_project, /batch_match (turf-based
point-in-polygon for assigning trees to a project automatically).routes/treeSurvey.js)GET /tree_survey — list (paginated, projectAuthFilter).GET /tree_survey/map — slim payload for map rendering (only valid
coordinates, placeholder rows excluded).GET /tree_survey/by_id/:id, /by_project/:projectName, /by_area/:areaName.POST /tree_survey/create_v2 (≥調查管理員 + projectAuth) — V2 tree
creation flow.PUT /tree_survey/update_v2/:id (≥調查管理員 + projectAuth).DELETE /tree_survey/:id and /tree_survey/placeholder/:id
(≥專案管理員 + projectAuth).POST /tree_survey/:id/retire (≥調查管理員 + projectAuth) — soft-retire a
tree (lifecycle_status ∈ dead/fallen/removed); keeps history/photos,
excludes it from living-biomass carbon totals and the maintenance queue.
Matches the maintenance workflow (surveyors report dead/fallen/removed).POST /tree_survey/:id/restore (≥調查管理員 + projectAuth) — set
lifecycle_status back to active.POST /tree_survey/batch_import (≥調查管理員 + projectAuth).POST /tree_survey/import (≥專案管理員) — Excel/CSV upload (Multer).GET /tree_survey/template — Excel template download.GET /tree_survey/next_system_number and /next_project_number/:projectCode.GET /tree_survey/common_species/:projectCode.routes/treeSpecies.js)/search, /enhanced (每個樹種帶出其同義詞清單),
/synonyms/report, /synonyms/merge (≥系統管理員), /next_number.routes/pending_measurements.js)POST /pending-measurements/batch — create a batch (V3 workflow).GET /sessions, GET /trees, GET /stats/overview.GET /:id, PATCH /:id, POST /transfer — convert pending → real tree
in tree_survey.PATCH /session/:sessionId/project, DELETE /session/:sessionId.routes/reports.js)GET /export/excel (≥調查管理員) — ExcelJS, with project filter.GET /export/pdf (≥調查管理員) — PDFKit.GET /sustainability_report (≥調查管理員) — calls reportController.routes/statistics.js)GET /tree_statistics — counts, averages, carbon totals, with project filter.routes/ai.js)POST /chat (≥調查管理員, aiLimiter) — primary endpoint. Pipeline:
sessionId if absent (<userId>_<yyyymmdd>).sqlQueryService.getHistoryQuerySQL —
WHERE user_id=$1 AND session_id=$2 AND created_at > NOW() - INTERVAL '15 minutes',
up to 10 rows.shouldQueryDatabase() — keyword + scoring intent classifier.validateSQL() (whitelist tables;
blacklist of INSERT/UPDATE/DELETE/..., XP_CMDSHELL, PG_SLEEP,
CHAR()/CHR()/hex/Unicode escape, OR 1=1, etc.) → run with
forced LIMIT ≤ 100 → format result → if rows > 5, generate Excel
and return a download URL under /api/download/:filename.chat_logs with chat_mode='chat' and the model name.
In production a model whitelist is enforced (DeepSeek-V3, Qwen3-235B/32B,
QwQ-32B, GPT-5-nano/mini/5.1, Gemini 2.5-flash/pro). Anything else is
silently rewritten to DeepSeek-V3.POST /ai/direct-chat (≥調查管理員) — bypass intent classifier; raw LLM.GET /reports/ai-sustainability[/pdf] (≥調查管理員) — AI-generated report.POST /sustainability-policy, GET /carbon-education/:topic,
POST /carbon-footprint/advice, POST /species-comparison.GET /download/:filename — temp Excel exports (exports/, auto-cleaned
every 30 min for files older than 1 hour).routes/agent.js + services/agentService.js)POST /agent/chat (≥調查管理員, agentLimiter 30/10 min) — ReAct loop. Default
model gpt-5.4-mini (AGENT_DEFAULT_MODEL, OpenAI-first); SiliconFlow models
are an optional fallback chain (useSiliconFlowFirst=false). Tools:
query_tree_data(query, project_area?) → reuses sqlQueryService to
run safe Text-to-SQL.calculate_carbon(dbh_cm, height_m?, species?) → Chave 2014 allometry.species_carbon_info(species_name) → 以 tree_survey 實際調查資料統計(舊 tree_carbon_data 靜態表已移除)。project_summary(project_area?).carbon_report(...).
Memory: 5 prior turns of the same (user_id, session_id, chat_mode='agent').
Per-user token budget: 50 000 tokens / hour, persisted in
agent_token_usage (so cluster workers and restarts share the counter).
Hard cap: 8 tool-call iterations per turn. The reply summary, tool list,
and tokensUsed are stored in chat_logs.metadata.GET /agent/status (≥調查管理員), GET /agent/models (≥調查管理員).routes/carbon.js / routes/carbon_data.js(carbon-sink、credit、education、
species-comparison 等)與 tree_carbon_data 靜態表已整批移除。services/carbonCalculationService.js /
handbookCarbonService.js 以程式內公式 + tree_survey 欄位計算。routes/management.js)POST /tree-management/actions/generate (≥專案管理員)GET /actions (≥調查管理員), PUT/DELETE /actions/:action_id (≥專案管理員)routes/location.js, routes/project_areas.js)POST /location/validate, /suggest_area.GET /project_areas/county_by_coords?lng=&lat= — returns the official
county for a coordinate. All county lookups (this route, project-area
auto-fill on submit, and scripts/backfill_county.js) share a single
helper utils/geo.js which loads data/tw_county.geojson (Ministry of
Interior 1140318 official boundaries, 22 counties, TWD97 lat/lon) and uses
@turf/turf booleanPointInPolygon with a bbox prefilter. The legacy
data/twCounty2010.fixed.geo.json is no longer read.routes/knowledge.js + tree_knowledge_embeddings_v2 + pgvector)
已整批移除;現行 /chat、Agent 皆不使用。routes/speciesIdentification.js)POST /species/identify — multipart image; PlantNet → fallback heuristics.GET /species/search, /gbif/:name, /inaturalist/:id, /status.routes/ml_service.js)GET /ml-service/status, /ml-service/config. The Flutter app calls the
Python service directly using the URL + API key returned at login; this
proxy is for diagnostics from a browser without exposing ML_API_KEY.routes/tree_images.js)POST /tree-images/upload (Multer + Cloudinary), GET /:id,
GET /tree/:treeId, DELETE /:id (≥專案管理員).routes/ml_training_data.js)POST /ml-training/batch, /image. Reads/exports under ≥業務管理員.routes/csvImport.js)POST /admin/import-csv/preview and /execute (≥業務管理員).routes/ipBlacklist.js)GET / /stats, POST /, DELETE /:ip. All ≥系統管理員.routes/admin.js)POST /admin/run-script (≥系統管理員), /backup, /restore.GET/POST/DELETE /admin/apikeys (≥系統管理員) — encrypted at rest.routes/webhook.js)POST /webhook/deploy — HMAC-SHA256 (DEPLOY_WEBHOOK_SECRET); only push
events on refs/heads/main trigger scripts/deploy.sh.GET /webhook/status — recent deploy log lines (X-Admin-Token only).Started by app.listen in app.js:
cleanupOldChatLogs().cleanupOrphanedPlaceholders →
cleanupUnusedSpecies → cleanupUnusedProjectAreas (which also heals
dangling projects.area_id refs) → cleanupOldChatLogs →
cleanupOldLoginAttempts → scheduledSynonymMaintenance.Defined in utils/cleanup.js and services/{ipBlacklistService,speciesSynonymService}.js:
| Routine | What it does |
|---|---|
cleanupOrphanedPlaceholders | DELETE FROM tree_survey WHERE species_name='預設樹種' |
cleanupUnusedSpecies | Remove tree_species rows with no tree_survey reference, > 30 days old, not the special '0000' row, not referenced by species_synonyms. Falls back gracefully if species_synonyms does not yet exist |
cleanupUnusedProjectAreas | Triple-NOT EXISTS guard against tree_survey, projects.area_id, project_boundaries.project_area. Then UPDATE projects SET area_id = NULL for any remaining dangling reference |
cleanupOldChatLogs | DELETE FROM chat_logs WHERE created_at < NOW() - INTERVAL '24 hours' |
cleanupOldLoginAttempts | Trim login_attempts and ip_login_attempts to the configured window |
scheduledSynonymMaintenance | Refresh canonical mappings cache |
Other intervals running outside app.js:
routes/ai.js: every 30 min — purge exports/ files older than 1 hour.PostgreSQL. Schema files live in database/initial_data/ and are executed in
this fixed order by scripts/migrate.js:
00_init_functions.pg.sql update_updated_at_column() shared trigger
users.pg.sql users + ENUM user_role (schema only; no seed rows)
system_settings_and_audit.pg.sql audit_logs (system_settings 已於 migration 25 移除)
project_areas.pg.sql project_areas table (schema only; demo ports in dev-fixtures/)
tree_species.pg.sql master species list
tree_survey.pg.sql main survey table (~7 k rows)
00_normalization_schema.pg.sql normalization helpers (must run after
tree_survey exists)
tree_management_actions.pg.sql AI 管理建議表(功能保留、前端暫無入口;冪等建表、不灌 demo)
chat_logs.pg.sql
02_chat_logs_add_session.pg.sql adds session_id
04_chat_logs_agent_mode.pg.sql adds chat_mode + metadata JSONB
01_sync_project_id_trigger.sql auto-creates a projects row when a
tree_survey row arrives with a new
project_code; superseded by 07_…
ml_training_data.pg.sql
z_pending_tree_measurements.pg.sql V3 two-stage measurement workflow
tree_images.pg.sql Cloudinary references, FK to tree_survey
species_synonyms.pg.sql synonyms + species_merge_log
03_user_projects.pg.sql junction table; backfilled from
users.associated_projects
05_ip_blacklist.pg.sql ip_blacklist + ip_login_attempts
06a_project_boundaries_schema.pg.sql project_boundaries table (schema only, no
seed). Demo 35-port polygons are dev-fixtures:
`node scripts/seed_dev_boundaries.js`.
07_backfill_projects_area_id.pg.sql Heal projects rows that the auto-create
trigger left with NULL area_id; also
repairs placeholder names by copying the
dominant project_name from tree_survey.
Idempotent.
After all tables are created, migrate.js:
COPY tree_survey FROM tree_survey_data.csv (a Transform stream rewrites
0000-00-00 00:00:00 to NULL on the fly).tree_survey_with_areas view.setval(pg_get_serial_sequence('tree_survey','id'), MAX(id)) to keep the
auto-increment in sync after the COPY.populateSpeciesRegionScore().To add a migration: drop a NN_*.pg.sql file under initial_data/ and add
its filename to the migrationFiles array in scripts/migrate.js.
ml_service/ is a separate FastAPI / Python app that the Flutter client
calls directly (not via the Node backend). The Node backend only stores
and forwards the URL + key to the client at login time.
Endpoints (ml_service/app.py):
GET /health, GET /api/v1/healthGET /api/v1/configPOST /api/v1/estimate-depth (auth)POST /api/v1/measure-dbh (auth)POST /api/v1/auto-measure-dbh (auth)POST /api/v1/auto-measure-dbh-multi (auth)POST /api/v1/debug/depth-at-point (auth)WebSocket /ws/scan — live scan frames.Auth: X-ML-API-Key header (or Authorization: Bearer …), compared with
hmac.compare_digest. Requests without the key are accepted only in dev
mode (ML_API_KEY unset). CORS origins come from ML_CORS_ORIGINS.
Models loaded (production default, start.ps1 -Preset da3): Depth-Anything-3
Metric Large as OpenVINO IR on the Intel NPU for depth, plus a
server-side YOLOv8-seg mask on the Intel iGPU (ML_FORCE_SERVER_YOLO=true,
ML_ENABLE_SAM=false). Apple Depth Pro and SAM 2.1 Tiny remain
available as alternative presets. See ml_service/README.md for the full
preset matrix and setup.
ml_service/start.ps1 is the canonical Windows launcher. It loads
ml_service/.env, picks an env preset, prints a config summary, runs a GPU
probe (Intel XPU / CUDA / CPU), and launches uvicorn.
cd backend\ml_service
.\start.ps1 # default model (DA V2 Base, CPU)
.\start.ps1 -Preset pro # Depth Pro, PyTorch
.\start.ps1 -Preset pro_ov # Depth Pro + OpenVINO INT8-W on iGPU (recommended)
.\start.ps1 -Verify # enable numpy verification
.\start.ps1 -Workers 2 -Port 8101
Key env vars consumed (also documented in ml_service/README*):
ML_DEPTH_MODEL, ML_USE_OPENVINO, ML_ENABLE_SAM, ML_SEG_MODEL,
ML_API_KEY, ML_CORS_ORIGINS, PORT. On Linux production the same
process is supervised by pm2 / systemd and uses the venv at
ml_service/venv/.
ml_service/run_export.ps1 is a separate utility that exports the Depth Pro
weights to OpenVINO IR INT8-W (one-time setup; required before -Preset pro_ov works).
/opt/tree-app/
├── backend/ # this repo, deployed by deploy.sh
├── logs/ # PM2 + deploy.log
└── scripts/ # symlinks to backend/scripts
main on GitHub.POST /webhook/deploy → HMAC verified.routes/webhook.js runs bash scripts/deploy.sh (timeout 120 s).deploy.sh:
.last_good_commit (only if /health is OK).git pull origin main.npm install --production.--skip-migrate).pm2 reload ecosystem.config.js (zero-downtime, cluster)./health; on failure, git reset --hard $LAST_GOOD and reload.pm2 reload ecosystem.config.js — reload after a manual pull.pm2 logs tree-backend — tail logs.scripts/rollback.sh — restore .last_good_commit.scripts/backup_db.sh — runs nightly via cron.Tests are plain Node scripts under tests/. There is no test framework; each
script runs assertions, prints, and exits non-zero on failure.
npm test # intent classification + sql validation
npm run test:integration # chat integration (text-to-sql end-to-end)
npm run test:api # /api routes (smoke)
npm run test:regression # full regression against prod
npm run test:regression:local
npm run test:all # regression + api + securityAudit
tests/regression.test.js reads
TEST_BASE_URL, TEST_ADMIN_USER/PASS, TEST_SURVEY_USER/PASS.
交接與技術文件統一維護在 frontend repo 的 docs/(單一正本)。本 repo 的 docs/README.md 為指標頁(含可點擊連結)。
| 主題 | 文件 |
|---|---|
| 文件總入口 | frontend docs/README.md |
| 系統總覽、本機啟動、測試 | HANDOFF.md |
| 架構、API、模組指南 | ARCHITECTURE.md, API_REFERENCE.md, CODEBASE_INVENTORY.md |
| Git / PR / CI | DEVELOPMENT_WORKFLOW.md |
| Ubuntu 主機從零部署 | LAB_DEPLOYMENT_GUIDE.md |
| 金鑰與環境設定 | HANDOFF_SECRETS_CHECKLIST.md |
| 物種 / PlantNet | SPECIES_AND_PLANTNET.md |
| 實驗功能 | EXPERIMENTAL_FEATURES.md |
| 本 repo 程式目錄 | docs/SOURCE_LAYOUT.md |
後端架構、API 清單、資料表、背景工作見本檔(上方各節)。
MIT — see LICENSE. Original development and primary maintenance by KyleliuNDHU; see AUTHORS.md.
399 commits
1 commits
JavaScript
67.9%
Python
27.9%
PLpgSQL
1.4%
Shell
1.4%
Node.js / Express REST API powering the Flutter app tree-project-frontend.
Handles authentication, tree-survey CRUD, project/area management, image
storage (Cloudinary), reverse-proxying to a Python ML service, an AI chat
assistant (Text-to-SQL) and a tool-using AI Agent.
Production runs on a self-hosted Ubuntu host behind a reverse proxy
(Nginx; the original deployment used Tailscale for private networking), with
PM2 cluster-mode supervision (see ecosystem.config.js).
Documentation hub (architecture, API, deploy, workflow):
frontend/docs/README.md → ONBOARDING_READING_PATH.md
Backend catalog (this repo): docs/README.md → docs/SOURCE_LAYOUT.md
flowchart TB
app["Flutter app<br/>sustainable_treeai (Android / iOS)"]
subgraph backendHost["Backend host (Ubuntu)"]
nbe["Nginx<br/>:443 → Node :3000<br/>(Tailscale Funnel exposes :443 publicly)"]
be["backend (this repo)<br/>Node 20 + Express<br/>PM2 cluster ×2 · ecosystem.config.js"]
nbe --> be
end
subgraph mlHost["ML host (separate machine, private network)"]
nml["Nginx<br/>:443 → uvicorn :8100"]
ml["ml_service/ (FastAPI)<br/>Depth Anything v3 metric-large<br/>OpenVINO FP16 on Intel NPU<br/>+ server YOLOv8m-seg on Intel iGPU<br/>+ pure pinhole DBH calculator"]
nml --> ml
end
ext[("PostgreSQL 15+ · Cloudinary · PlantNet / GBIF /<br/>iNaturalist · OpenAI / Gemini / SiliconFlow / Anthropic")]
app -- "HTTPS (TLS)" --> nbe
app -- "HTTPS + JWT<br/>multipart image / EXIF / bbox" --> nbe
be -- "multipart + X-ML-API-Key" --> nml
nml -- "DBH / mask / warnings" --> be
be --> ext
gh["GitHub push (main)"] -- "HMAC-SHA256" --> nbe
be -. triggers .-> deploy["scripts/deploy.sh<br/>git pull → npm install → migrate<br/>→ pm2 reload tree-backend → /health → auto-rollback"]
Two-service deployment with a Node ML proxy. The Flutter app talks to
the Node backend for CRUD/auth/AI and for static DBH measurement through
/api/ml-service/*. The backend then forwards multipart images to the
FastAPI ml_service and injects X-ML-API-Key from the host environment,
so the ML API key is not stored on the client. Legacy or experimental live
scan paths may still talk to the FastAPI host directly, but the production
DBH HTTP flow is Flutter → Node proxy → FastAPI.
The exact public hostnames, private-network IPs and reverse-proxy domains are deployment-specific and not committed to this repo. Configure them via environment variables (
BASE_URL,ML_SERVICE_URL,ML_SERVICE_PUBLIC_URL,CORS_ALLOWED_ORIGINS) on the host.
Each subsection traces one user-visible feature from the Flutter app down to the database / external services. Bracketed paths are files in this repo.
POST /api/login)flowchart TB
fp["Flutter LoginPage<br/>{account, password, loginType}"]
rl["middleware/loginRateLimiter<br/>10 req / 15 min / IP"]
bl["middleware/ipBlacklist"]
rt["routes/users.js POST /login<br/>WHERE username=account (+role check if admin)<br/>bcrypt.compare(password, users.password_hash)"]
db[("PostgreSQL · users<br/>(user_id, username, display_name, role, …)")]
jwt["JWT (HS256, 24 h)<br/>payload = {user_id, username, role}"]
resp["{token, user, mlConfig?}<br/>mlConfig.url only — no client ML API key"]
st["flutter_secure_storage (auth_jwt_token, user_info)<br/>SharedPreferences (ml_service_url, ml_api_key)"]
fp --> rl --> bl --> rt --> db --> jwt --> resp --> st
Logout is client-side only (delete token + clear ML credentials).
flowchart TB
fm["Flutter survey form (V2 / V3)<br/>multipart/form-data: fields + photo[]"]
jw["middleware/jwtAuth → req.user"]
up["middleware/upload (multer)<br/>memoryStorage · 10 MB · image/* only"]
rt["routes/treeSurvey.js POST /api/tree_survey/create_v2<br/>(photos via routes/tree_images.js POST /api/tree-images/upload)"]
cl["Cloudinary upload_stream(buffer)<br/>→ tree_images (cloud_url, thumbnail_url, public_id)"]
geo["resolveCountyByLngLat(lng, lat)<br/>utils/geo.js + data/tw_county.geojson<br/>(22 counties, MOI 1140318)"]
db[("PostgreSQL · tree_survey + tree_images<br/>single transaction")]
out["{ tree, photos[] }"]
fm --> jw --> up --> rt
rt --> cl
rt --> geo
rt --> db --> out
GET / PUT / DELETE follow the same auth + ownership-check pattern in routes/treeSurvey.js (tree records) and routes/tree_images.js (photos).
POST /api/chat)flowchart TB
cp["Flutter ChatPage<br/>POST /api/chat {message, model_preference, sessionId, projectAreas}"]
jw["jwtAuth + requireRole(調查管理員) + aiLimiter"]
rt["routes/ai.js"]
sql["gpt-4.1-nano drafts SQL<br/>(or NOT_A_DATA_QUERY → knowledge mode)"]
sv["services/sqlQueryService.executeSecureQuery<br/>security validation + 1 auto-retry · read-only"]
db[("PostgreSQL")]
exp["explain result with selected model<br/>(gpt-5-nano/mini/5.1, gemini-*, Qwen/*,<br/>deepseek-ai/DeepSeek-V3 default)"]
log[("chat_logs<br/>(user_id, message, response, model_used, session_id)")]
out["JSON response"]
cp --> jw --> rt --> sql --> sv --> db --> exp --> out
exp --> log
Response is a regular JSON payload (not SSE). Audit trail: every Q/A pair +
model + final response is written to chat_logs (see
routes/ai.js).
/api/agent/*)flowchart TB
ap["Flutter AgentPage<br/>POST /api/agent/chat {messages}"]
rt["routes/agent.js"]
svc["services/agentService.js<br/>tool-using loop · default model gpt-5.4-mini"]
t1["fetch_allowed_url(s) / search_public_documents<br/>(allow-listed policy URLs)"]
t2["list_policy_sources / list_allowed_domains / list_demo_policy_urls"]
t3["export_excel / export_pdf / export_ai_report"]
impl["services/agentDataTools.js (tool implementations)"]
llm["LLM: prefers SiliconFlow if keys configured,<br/>otherwise OpenAI; cross-provider fallback"]
out["JSON {answer, tool trace}"]
ap --> rt --> svc
svc --> t1 --> impl
svc --> t2 --> impl
svc --> t3 --> impl
svc --> llm --> out
flowchart TB
fp["Flutter measurement page (V3)<br/>image + EXIF + phone bbox<br/>Authorization: Bearer JWT"]
proxy["backend routes/ml_service.js<br/>POST /api/ml-service/auto-measure-dbh<br/>multer memory upload · JWT protected"]
app2["ml_service/app.py<br/>verify_api_key (hmac.compare_digest)<br/>RateLimitMiddleware (ML_RATE_LIMIT, default 30/hr/IP)"]
seg["ServerYOLO<br/>YOLOv8m-seg OpenVINO<br/>Intel iGPU · 832 px<br/>→ trunk mask"]
de["depth_estimation.py<br/>Depth Anything v3 metric-large<br/>OpenVINO FP16 · Intel NPU · 504×378<br/>→ monocular metric depth"]
dbh["dbh_calculator.py<br/>mask row chord width Wpx<br/>central 1/3 chord-depth z<br/>pure pinhole d = z · Wpx/fpx"]
out["{ dbh_cm, trunk_depth_m, trunk_pixel_width,<br/>quality_code, mask/debug images, confidence }"]
ui["Flutter form/scanner<br/>user confirms → tree_survey / pending measurement"]
fp --> proxy --> app2
app2 --> seg --> dbh
app2 --> de --> dbh
dbh --> out --> ui
The production static DBH flow goes through the Node proxy. WebSocket /ws/scan is kept for live preview experiments and should be treated as
optional unless it is routed through the same authentication path.
/api/species/*)flowchart TB
sp["Flutter SpeciesIdentifyPage<br/>POST /api/species/identify (image)"]
rt["routes/speciesIdentification.js"]
svc["services/speciesIdentificationService"]
pn["PlantNet REST API<br/>(org=tree, lang=zh-tw)"]
add["autoAddSpeciesFromIdentification()<br/>insert tree_species (scientific_name, …)<br/>insert common_names → species_synonyms"]
out["{ candidates[],<br/>top: {species_id, scientific_name,<br/>common_name, score} }"]
sp --> rt --> svc
svc --> pn --> svc
svc --> add
svc --> out
V3 form auto-creates a species row at submit time (services/speciesIdentificationService.js) so the user never has to "save species" manually.
flowchart TB
cl["client<br/>GET /api/project_areas/county_by_coords?lng=&lat="]
rt["routes/project_areas.js"]
geo["utils/geo.js · resolveCountyByLngLat<br/>data/tw_county.geojson (cached on first call)<br/>bbox prefilter → booleanPointInPolygon (@turf/turf)"]
out["{ name: '嘉義縣', code: '10010',<br/>eng: 'Chiayi County' } or null"]
cl --> rt --> geo --> out
scripts/backfill_county.js --apply re-runs the helper for every
projects.area row whose county is NULL.
flowchart TB
gh["GitHub push to main<br/>POST https://<public-host>/webhook/deploy<br/>X-Hub-Signature-256: sha256=…"]
wh["routes/webhook.js<br/>hmac(DEPLOY_WEBHOOK_SECRET) timing-safe"]
sp["spawn bash scripts/deploy.sh<br/>(timeout 120 s, log → logs/deploy.log)"]
s1["/health check<br/>save SHA → .last_good_commit"]
s2["git pull origin main"]
s3["npm install --production"]
s4["run pending migrations<br/>(skip with --skip-migrate)"]
s5["pm2 reload ecosystem.config.js<br/>(zero-downtime)"]
s6{"curl /health"}
ok(["done"])
rb["git reset --hard $LAST_GOOD<br/>pm2 reload · alert"]
gh --> wh --> sp --> s1 --> s2 --> s3 --> s4 --> s5 --> s6
s6 -- pass --> ok
s6 -- fail --> rb
GET /webhook/status (Bearer ADMIN_API_TOKEN) returns the tail of
logs/deploy.log.
pg + pg-format + pg-copy-streams)jsonwebtoken, HS256, fixed algorithm)openai, @anthropic-ai/sdk, @google/generative-ai@turf/turf for point-in-polygon (county auto-detection, project boundaries)ecosystem.config.js)cd backend
cp tree-app-backend-prod.env .env # then edit values (see Configuration)
npm install
node scripts/migrate.js # fresh empty DB: schema only (+ optional dev CSV)
node scripts/seed_dev_users.js # dev/CI only: test accounts (NOT for production)
npm run dev # nodemon on PORT (default 3000)
Health check: GET /health → 200 OK (no auth required).
In production mode, app.js runs scripts/run_pending_migrations.js
automatically on startup (schema only; no CSV import). For a fresh empty DB,
run node scripts/migrate.js manually once.
All configuration is via environment variables loaded with dotenv from
.env. The keys actually read by the code:
| Key | Default | Notes |
|---|---|---|
NODE_ENV | — | When production: required env-vars enforced, migrations auto-run, error responses are sanitized, model whitelist enforced for AI chat |
PORT | 3000 | Express listen port |
BASE_URL | http://localhost:${PORT} | Used to build full URLs for AI chat Excel-export download links |
config/db.js)| Key | Notes |
|---|---|
DATABASE_URL | PostgreSQL connection string. Required in production |
DB_HOST / DB_USER / DB_PASSWORD / DB_NAME / DB_PORT | Discrete connection params (fallback when DATABASE_URL is unset). First admin: node scripts/create_lab_admin.js (production) or seed_dev_users.js (dev/CI only) |
DB_SSL_REJECT_UNAUTHORIZED | true to enforce TLS cert verification; default false (typical for Render-style providers) |
middleware/jwtAuth.js)| Key | Notes |
|---|---|
JWT_SECRET | Required in production. HS256 only — algorithm-confusion attack is blocked |
JWT_EXPIRES_IN | Default 24h |
app.js)| Key | Notes |
|---|---|
CORS_ALLOWED_ORIGINS (or CORS_ORIGIN) | Comma-separated whitelist. In production, an empty list = deny all cross-origin. Same-origin and missing Origin header (mobile apps) are always allowed |
config/cloudinary.js)| Key | Notes |
|---|---|
CLOUDINARY_CLOUD_NAME / CLOUDINARY_API_KEY / CLOUDINARY_API_SECRET | All three needed for upload to be active. If unset, image upload is disabled (the code returns a clear warning instead of crashing) |
routes/ml_service.js, routes/users.js)| Key | Notes |
|---|---|
ML_SERVICE_URL | Internal URL of the Python FastAPI service (e.g. http://127.0.0.1:8100) |
ML_SERVICE_PUBLIC_URL | Optional. Public URL returned to the client by /login; falls back to ML_SERVICE_URL |
ML_API_KEY | Sent as X-ML-API-Key to the FastAPI service |
| Key | Used by | Notes |
|---|---|---|
GEMINI_API_KEY | services/geminiService.js | Google Gemini (chat + report generation) |
OPENAI_API_KEY | services/openaiService.js, routes/ai.js, routes/admin.js | OpenAI |
Claude_API_KEY | routes/ai.js, routes/admin.js, scripts/generate_species_knowledge.js | Anthropic |
SiliconFlow_API_KEY, Alt1_SiliconFlow_API_KEY, Alt2_…, Alt3_… | services/agentService.js | SiliconFlow (free tier). Up to 4 keys are loaded; agent rotates to the next when one is exhausted |
PLANTNET_API_KEY | services/speciesIdentificationService.js, routes/speciesIdentification.js | PlantNet (image-based species ID) |
routes/webhook.js)| Key | Notes |
|---|---|
DEPLOY_WEBHOOK_SECRET | HMAC-SHA256 secret. Validated against X-Hub-Signature-256 using a timing-safe compare |
ADMIN_API_TOKEN | Token to read GET /webhook/status (deploy log tail). The only place a static token is still accepted |
| Key | Notes |
|---|---|
DISABLE_IP_GUARD | true to disable the IP-blacklist middleware (testing only) |
TEST_BASE_URL, TEST_ADMIN_USER, TEST_ADMIN_PASS, TEST_SURVEY_USER, TEST_SURVEY_PASS | Used by tests/regression.test.js and tests/apiIntegration.test.js |
package.json:
| Script | Command |
|---|---|
npm start | node app.js |
npm run dev | nodemon app.js |
npm test | Intent classification + SQL validation tests |
npm run test:intent | Intent classifier only |
npm run test:sql | SQL validation only |
npm run test:integration | Chat integration |
npm run test:api | API integration |
| `npm run test:regression[:local | :verbose]` |
npm run test:all | Regression + API + security audit |
The tests under tests/ are plain Node scripts, not a framework.
scripts/:
| File | Purpose |
|---|---|
migrate.js | Reads schema files in database/initial_data/ in a fixed order, executes them in one transaction-friendly sequence, COPYs tree_survey_data.csv (with on-the-fly date sanitization), creates views, and resets the tree_survey PK sequence. Idempotent — every migration uses IF NOT EXISTS / CREATE OR REPLACE / safe upserts |
deploy.sh | Production auto-deploy: saves rollback point, git pull, npm install --production, runs migrations (unless --skip-migrate), PM2 graceful reload, health-check; auto-rollback on failure |
rollback.sh | Manual rollback to .last_good_commit |
backup_db.sh | pg_dump wrapper for the nightly cron |
health_check.sh | Curl /health, used by external monitoring |
populateSpeciesRegionScore.js | Computes per-region species suitability scores. Run inside migrate.js automatically |
enrich_species_synonyms.js | LLM-assisted synonym expansion (deprecated — superseded by Text-to-SQL) |
generate_species_knowledge.js, populate_knowledge.js, populate_knowledge_from_survey.js, generateEmbeddings.js | Knowledge-base / RAG seed scripts. Not part of the standard pipeline since the 2025-11 switch to Text-to-SQL; kept for the legacy embedding table |
migrate_placeholder_fix.js | One-off historical cleanup; safe to re-run |
backend/
├── app.js # Entry. Trust proxy → CORS → helmet → JSON
│ # → /health → /webhook → /api(ipBlacklist→
│ # burstLimiter→apiLimiter→jwtAuth→router)
│ # → global error handler. Hourly cleanup
│ # cron and one-shot 5s-after-startup
│ # chat-log cleanup are also kicked off here.
├── config/
│ ├── db.js # pg.Pool (DATABASE_URL, optional SSL)
│ ├── cloudinary.js # SDK config + isCloudinaryConfigured guard
│ └── apiKeys.js # Static helper for AI key lookup
├── controllers/ # Thin handlers called from routes/
│ ├── treeSurveyCreateController.js / *Update / *Batch
│ ├── treeManagementController.js
│ ├── carbonSinkController.js
│ ├── csvImportController.js
│ ├── reportController.js / aiReportController.js
│ ├── openaiController.js
│ ├── aiController.js
│ └── knowledgeController.js
├── middleware/
│ ├── jwtAuth.js # HS256, Bearer-only, OPTIONS + /login skip
│ ├── rateLimiter.js # apiLimiter / burstLimiter / loginLimiter / aiLimiter
│ ├── ipBlacklistGuard.js # Reads ip_blacklist row; auto-expires
│ ├── loginAttemptMonitor.js # 5-strikes per account, 30-min lock
│ ├── projectAuth.js # Per-resource project-permission check (5-min cache)
│ └── roleAuth.js # 5-tier RBAC (see "Auth model")
├── routes/ # 24 modules — see "API surface"
├── services/
│ ├── sqlQueryService.js # Text-to-SQL: intent classifier, schema
│ │ # info, SQL safety validator (forbidden
│ │ # keywords/patterns, complexity guard,
│ │ # whitelist), result formatter
│ ├── agentService.js # ReAct Agent: tool definitions, multi-key
│ │ # rotation, persistent token budget table,
│ │ # max 8 tool steps
│ ├── geminiService.js # Gemini chat
│ ├── openaiService.js # OpenAI chat (used by controllers)
│ ├── speciesIdentificationService.js # PlantNet wrapper + GBIF / iNaturalist enrichment
│ ├── speciesSynonymService.js # Synonym table + scheduled maintenance
│ ├── knowledgeEmbeddingService.js # pgvector cosine search (legacy RAG)
│ ├── auditLogService.js # Centralized audit-log writer
│ └── ipBlacklistService.js # recordOffense / cleanupOldLoginAttempts
├── utils/
│ ├── cleanup.js # Hourly maintenance (see "Background jobs")
│ └── …
├── database/
│ └── initial_data/ # Schema + seed + post-schema fix-up SQL
│ # (see "Database")
├── ml_service/ # Standalone Python FastAPI service
├── scripts/ # Operational scripts (above)
├── tests/ # Plain Node tests
├── docs/ # Internal design notes
├── ecosystem.config.js # PM2 config (cluster, 2 workers)
└── tree-app-backend-prod.env # Sample production env-file (do not commit secrets)
trust proxy (Tailscale / Nginx)
│
▼
CORS — whitelist from CORS_ALLOWED_ORIGINS (empty in prod = deny)
│
▼
helmet — standard security headers
│
▼
express.json (limit 10 MB; rawBody captured for /webhook routes)
│
├─► GET /health (no auth)
│
├─► POST /webhook/deploy (HMAC-SHA256 verified)
├─► GET /webhook/status (X-Admin-Token)
│
└─► /api/*
│
▼
ipBlacklistGuard
│ (reads ip_blacklist; auto-expires; can be disabled with
│ DISABLE_IP_GUARD=true for local testing)
▼
burstLimiter — 20 req / 10 s per IP. Excess writes ip_blacklist
▼ with offense-count escalation (5-min base ban)
apiLimiter — 500 req / 15 min per IP
▼
jwtAuth — Bearer HS256; OPTIONS and /login are skipped
▼
Router — see "API surface"
│
▼
global error handler — logs full stack; in prod returns "伺服器發生未預期的錯誤"
JWT-only authentication (the historical X-Admin-Token fallback was removed
on 2026-04-27). Token payload (set in routes/users.js after login) includes
user_id, username, role, and is signed HS256.
middleware/roleAuth.js)| Level | Role | Powers |
|---|---|---|
| 5 | 系統管理員 | Backup/restore, user mgmt, all projects, IP blacklist mgmt |
| 4 | 業務管理員 | User mgmt, project create/delete, all projects' data |
| 3 | 專案管理員 | Boundaries, area CRUD, delete trees on owned projects |
| 2 | 調查管理員 | Add/edit/retire/restore trees, import/export, AI chat & agent (own projects) |
| 1 | 一般使用者 | Read-only on own projects |
requireRole('業務管理員') admits anyone with a role of equal or higher
level. req.isAdmin is set when level ≥ 3.
Two middlewares in middleware/projectAuth.js:
projectAuth — for write/delete on a single resource. Looks up
user_projects (level ≥ 4 always passes; deeper levels checked against the
junction table). For PUT/DELETE without a body project_code, the
middleware first reads the resource's project_code from tree_survey.projectAuthFilter — for list endpoints. Sets req.projectFilter to
either null (no restriction) or an array of project codes the user can
see; routes apply it as WHERE project_code = ANY($1::text[]).A 5-minute in-memory cache (_cache in projectAuth.js) holds each user's
project list to avoid hitting the DB on every request.
invalidateUserProjectsCache(userId) is called after permission edits.
loginAttemptMonitor.js).loginLimiter: 50 attempts / hour per IP.burstLimiter: 20 req / 10 s per IP. Triggers escalating ban writes to
ip_blacklist (recordOffense).LOGIN_FAILED and LOGIN events go to audit_logs via auditLogService.All endpoints below are mounted under /api. They require a valid JWT, with
an additional minimum role where indicated.
routes/users.js)POST /login — accepts { account, password, loginType: 'admin' | … }. Admin
login is restricted to roles 系統管理員 / 業務管理員 / 專案管理員 / 調查管理員.
Returns JWT + accessible projects + ML service config (URL + API key).GET /users (≥業務管理員)POST /users (≥業務管理員)PUT /users/:id (≥業務管理員)PUT /users/:id/status (≥業務管理員) — enable/disableDELETE /users/:id (≥業務管理員)GET /users/:userId/projects / PUT /users/:userId/projects (≥業務管理員)POST /register — public invite-code registration (loginRateLimiter).
Consumes an invite code; if the code has requires_approval, the new account
is created with is_active=false / pending_approval=true for admin review.GET /invites / POST /invites (≥業務管理員) — list / create invite codes
(role ≤ creator, max_uses, expires_in_days, project_codes[]).PATCH /invites/:inviteId/deactivate (≥業務管理員) — disable a code.DELETE /invites/:inviteId (≥業務管理員) — delete (writes audit DELETE_INVITE).
See ADMIN_AND_INVITE_DESIGN.md for the full design.routes/projects.js, routes/project_areas.js, routes/project_boundaries.js)GET /projects / /projects/by_area/:area / /projects/by_name/:name /
/projects/by_code/:code — filtered by projectAuthFilter. Each prefers
the projects table and falls back to SELECT DISTINCT FROM tree_survey
if the row is missing (legacy data path).POST /projects/add (≥業務管理員) — resolves area_id by area name lookup.DELETE /projects/:code (≥業務管理員)GET/POST/PUT/DELETE /project_areas (POST/PUT/DELETE need ≥專案管理員)POST /project_areas/cleanup (≥系統管理員) — manual run of the same routine
that the hourly cron triggers./project-boundaries: GeoJSON storage. Public reads, writes need ≥專案管理員.
Includes POST /check, /find_project, /batch_match (turf-based
point-in-polygon for assigning trees to a project automatically).routes/treeSurvey.js)GET /tree_survey — list (paginated, projectAuthFilter).GET /tree_survey/map — slim payload for map rendering (only valid
coordinates, placeholder rows excluded).GET /tree_survey/by_id/:id, /by_project/:projectName, /by_area/:areaName.POST /tree_survey/create_v2 (≥調查管理員 + projectAuth) — V2 tree
creation flow.PUT /tree_survey/update_v2/:id (≥調查管理員 + projectAuth).DELETE /tree_survey/:id and /tree_survey/placeholder/:id
(≥專案管理員 + projectAuth).POST /tree_survey/:id/retire (≥調查管理員 + projectAuth) — soft-retire a
tree (lifecycle_status ∈ dead/fallen/removed); keeps history/photos,
excludes it from living-biomass carbon totals and the maintenance queue.
Matches the maintenance workflow (surveyors report dead/fallen/removed).POST /tree_survey/:id/restore (≥調查管理員 + projectAuth) — set
lifecycle_status back to active.POST /tree_survey/batch_import (≥調查管理員 + projectAuth).POST /tree_survey/import (≥專案管理員) — Excel/CSV upload (Multer).GET /tree_survey/template — Excel template download.GET /tree_survey/next_system_number and /next_project_number/:projectCode.GET /tree_survey/common_species/:projectCode.routes/treeSpecies.js)/search, /enhanced (每個樹種帶出其同義詞清單),
/synonyms/report, /synonyms/merge (≥系統管理員), /next_number.routes/pending_measurements.js)POST /pending-measurements/batch — create a batch (V3 workflow).GET /sessions, GET /trees, GET /stats/overview.GET /:id, PATCH /:id, POST /transfer — convert pending → real tree
in tree_survey.PATCH /session/:sessionId/project, DELETE /session/:sessionId.routes/reports.js)GET /export/excel (≥調查管理員) — ExcelJS, with project filter.GET /export/pdf (≥調查管理員) — PDFKit.GET /sustainability_report (≥調查管理員) — calls reportController.routes/statistics.js)GET /tree_statistics — counts, averages, carbon totals, with project filter.routes/ai.js)POST /chat (≥調查管理員, aiLimiter) — primary endpoint. Pipeline:
sessionId if absent (<userId>_<yyyymmdd>).sqlQueryService.getHistoryQuerySQL —
WHERE user_id=$1 AND session_id=$2 AND created_at > NOW() - INTERVAL '15 minutes',
up to 10 rows.shouldQueryDatabase() — keyword + scoring intent classifier.validateSQL() (whitelist tables;
blacklist of INSERT/UPDATE/DELETE/..., XP_CMDSHELL, PG_SLEEP,
CHAR()/CHR()/hex/Unicode escape, OR 1=1, etc.) → run with
forced LIMIT ≤ 100 → format result → if rows > 5, generate Excel
and return a download URL under /api/download/:filename.chat_logs with chat_mode='chat' and the model name.
In production a model whitelist is enforced (DeepSeek-V3, Qwen3-235B/32B,
QwQ-32B, GPT-5-nano/mini/5.1, Gemini 2.5-flash/pro). Anything else is
silently rewritten to DeepSeek-V3.POST /ai/direct-chat (≥調查管理員) — bypass intent classifier; raw LLM.GET /reports/ai-sustainability[/pdf] (≥調查管理員) — AI-generated report.POST /sustainability-policy, GET /carbon-education/:topic,
POST /carbon-footprint/advice, POST /species-comparison.GET /download/:filename — temp Excel exports (exports/, auto-cleaned
every 30 min for files older than 1 hour).routes/agent.js + services/agentService.js)POST /agent/chat (≥調查管理員, agentLimiter 30/10 min) — ReAct loop. Default
model gpt-5.4-mini (AGENT_DEFAULT_MODEL, OpenAI-first); SiliconFlow models
are an optional fallback chain (useSiliconFlowFirst=false). Tools:
query_tree_data(query, project_area?) → reuses sqlQueryService to
run safe Text-to-SQL.calculate_carbon(dbh_cm, height_m?, species?) → Chave 2014 allometry.species_carbon_info(species_name) → 以 tree_survey 實際調查資料統計(舊 tree_carbon_data 靜態表已移除)。project_summary(project_area?).carbon_report(...).
Memory: 5 prior turns of the same (user_id, session_id, chat_mode='agent').
Per-user token budget: 50 000 tokens / hour, persisted in
agent_token_usage (so cluster workers and restarts share the counter).
Hard cap: 8 tool-call iterations per turn. The reply summary, tool list,
and tokensUsed are stored in chat_logs.metadata.GET /agent/status (≥調查管理員), GET /agent/models (≥調查管理員).routes/carbon.js / routes/carbon_data.js(carbon-sink、credit、education、
species-comparison 等)與 tree_carbon_data 靜態表已整批移除。services/carbonCalculationService.js /
handbookCarbonService.js 以程式內公式 + tree_survey 欄位計算。routes/management.js)POST /tree-management/actions/generate (≥專案管理員)GET /actions (≥調查管理員), PUT/DELETE /actions/:action_id (≥專案管理員)routes/location.js, routes/project_areas.js)POST /location/validate, /suggest_area.GET /project_areas/county_by_coords?lng=&lat= — returns the official
county for a coordinate. All county lookups (this route, project-area
auto-fill on submit, and scripts/backfill_county.js) share a single
helper utils/geo.js which loads data/tw_county.geojson (Ministry of
Interior 1140318 official boundaries, 22 counties, TWD97 lat/lon) and uses
@turf/turf booleanPointInPolygon with a bbox prefilter. The legacy
data/twCounty2010.fixed.geo.json is no longer read.routes/knowledge.js + tree_knowledge_embeddings_v2 + pgvector)
已整批移除;現行 /chat、Agent 皆不使用。routes/speciesIdentification.js)POST /species/identify — multipart image; PlantNet → fallback heuristics.GET /species/search, /gbif/:name, /inaturalist/:id, /status.routes/ml_service.js)GET /ml-service/status, /ml-service/config. The Flutter app calls the
Python service directly using the URL + API key returned at login; this
proxy is for diagnostics from a browser without exposing ML_API_KEY.routes/tree_images.js)POST /tree-images/upload (Multer + Cloudinary), GET /:id,
GET /tree/:treeId, DELETE /:id (≥專案管理員).routes/ml_training_data.js)POST /ml-training/batch, /image. Reads/exports under ≥業務管理員.routes/csvImport.js)POST /admin/import-csv/preview and /execute (≥業務管理員).routes/ipBlacklist.js)GET / /stats, POST /, DELETE /:ip. All ≥系統管理員.routes/admin.js)POST /admin/run-script (≥系統管理員), /backup, /restore.GET/POST/DELETE /admin/apikeys (≥系統管理員) — encrypted at rest.routes/webhook.js)POST /webhook/deploy — HMAC-SHA256 (DEPLOY_WEBHOOK_SECRET); only push
events on refs/heads/main trigger scripts/deploy.sh.GET /webhook/status — recent deploy log lines (X-Admin-Token only).Started by app.listen in app.js:
cleanupOldChatLogs().cleanupOrphanedPlaceholders →
cleanupUnusedSpecies → cleanupUnusedProjectAreas (which also heals
dangling projects.area_id refs) → cleanupOldChatLogs →
cleanupOldLoginAttempts → scheduledSynonymMaintenance.Defined in utils/cleanup.js and services/{ipBlacklistService,speciesSynonymService}.js:
| Routine | What it does |
|---|---|
cleanupOrphanedPlaceholders | DELETE FROM tree_survey WHERE species_name='預設樹種' |
cleanupUnusedSpecies | Remove tree_species rows with no tree_survey reference, > 30 days old, not the special '0000' row, not referenced by species_synonyms. Falls back gracefully if species_synonyms does not yet exist |
cleanupUnusedProjectAreas | Triple-NOT EXISTS guard against tree_survey, projects.area_id, project_boundaries.project_area. Then UPDATE projects SET area_id = NULL for any remaining dangling reference |
cleanupOldChatLogs | DELETE FROM chat_logs WHERE created_at < NOW() - INTERVAL '24 hours' |
cleanupOldLoginAttempts | Trim login_attempts and ip_login_attempts to the configured window |
scheduledSynonymMaintenance | Refresh canonical mappings cache |
Other intervals running outside app.js:
routes/ai.js: every 30 min — purge exports/ files older than 1 hour.PostgreSQL. Schema files live in database/initial_data/ and are executed in
this fixed order by scripts/migrate.js:
00_init_functions.pg.sql update_updated_at_column() shared trigger
users.pg.sql users + ENUM user_role (schema only; no seed rows)
system_settings_and_audit.pg.sql audit_logs (system_settings 已於 migration 25 移除)
project_areas.pg.sql project_areas table (schema only; demo ports in dev-fixtures/)
tree_species.pg.sql master species list
tree_survey.pg.sql main survey table (~7 k rows)
00_normalization_schema.pg.sql normalization helpers (must run after
tree_survey exists)
tree_management_actions.pg.sql AI 管理建議表(功能保留、前端暫無入口;冪等建表、不灌 demo)
chat_logs.pg.sql
02_chat_logs_add_session.pg.sql adds session_id
04_chat_logs_agent_mode.pg.sql adds chat_mode + metadata JSONB
01_sync_project_id_trigger.sql auto-creates a projects row when a
tree_survey row arrives with a new
project_code; superseded by 07_…
ml_training_data.pg.sql
z_pending_tree_measurements.pg.sql V3 two-stage measurement workflow
tree_images.pg.sql Cloudinary references, FK to tree_survey
species_synonyms.pg.sql synonyms + species_merge_log
03_user_projects.pg.sql junction table; backfilled from
users.associated_projects
05_ip_blacklist.pg.sql ip_blacklist + ip_login_attempts
06a_project_boundaries_schema.pg.sql project_boundaries table (schema only, no
seed). Demo 35-port polygons are dev-fixtures:
`node scripts/seed_dev_boundaries.js`.
07_backfill_projects_area_id.pg.sql Heal projects rows that the auto-create
trigger left with NULL area_id; also
repairs placeholder names by copying the
dominant project_name from tree_survey.
Idempotent.
After all tables are created, migrate.js:
COPY tree_survey FROM tree_survey_data.csv (a Transform stream rewrites
0000-00-00 00:00:00 to NULL on the fly).tree_survey_with_areas view.setval(pg_get_serial_sequence('tree_survey','id'), MAX(id)) to keep the
auto-increment in sync after the COPY.populateSpeciesRegionScore().To add a migration: drop a NN_*.pg.sql file under initial_data/ and add
its filename to the migrationFiles array in scripts/migrate.js.
ml_service/ is a separate FastAPI / Python app that the Flutter client
calls directly (not via the Node backend). The Node backend only stores
and forwards the URL + key to the client at login time.
Endpoints (ml_service/app.py):
GET /health, GET /api/v1/healthGET /api/v1/configPOST /api/v1/estimate-depth (auth)POST /api/v1/measure-dbh (auth)POST /api/v1/auto-measure-dbh (auth)POST /api/v1/auto-measure-dbh-multi (auth)POST /api/v1/debug/depth-at-point (auth)WebSocket /ws/scan — live scan frames.Auth: X-ML-API-Key header (or Authorization: Bearer …), compared with
hmac.compare_digest. Requests without the key are accepted only in dev
mode (ML_API_KEY unset). CORS origins come from ML_CORS_ORIGINS.
Models loaded (production default, start.ps1 -Preset da3): Depth-Anything-3
Metric Large as OpenVINO IR on the Intel NPU for depth, plus a
server-side YOLOv8-seg mask on the Intel iGPU (ML_FORCE_SERVER_YOLO=true,
ML_ENABLE_SAM=false). Apple Depth Pro and SAM 2.1 Tiny remain
available as alternative presets. See ml_service/README.md for the full
preset matrix and setup.
ml_service/start.ps1 is the canonical Windows launcher. It loads
ml_service/.env, picks an env preset, prints a config summary, runs a GPU
probe (Intel XPU / CUDA / CPU), and launches uvicorn.
cd backend\ml_service
.\start.ps1 # default model (DA V2 Base, CPU)
.\start.ps1 -Preset pro # Depth Pro, PyTorch
.\start.ps1 -Preset pro_ov # Depth Pro + OpenVINO INT8-W on iGPU (recommended)
.\start.ps1 -Verify # enable numpy verification
.\start.ps1 -Workers 2 -Port 8101
Key env vars consumed (also documented in ml_service/README*):
ML_DEPTH_MODEL, ML_USE_OPENVINO, ML_ENABLE_SAM, ML_SEG_MODEL,
ML_API_KEY, ML_CORS_ORIGINS, PORT. On Linux production the same
process is supervised by pm2 / systemd and uses the venv at
ml_service/venv/.
ml_service/run_export.ps1 is a separate utility that exports the Depth Pro
weights to OpenVINO IR INT8-W (one-time setup; required before -Preset pro_ov works).
/opt/tree-app/
├── backend/ # this repo, deployed by deploy.sh
├── logs/ # PM2 + deploy.log
└── scripts/ # symlinks to backend/scripts
main on GitHub.POST /webhook/deploy → HMAC verified.routes/webhook.js runs bash scripts/deploy.sh (timeout 120 s).deploy.sh:
.last_good_commit (only if /health is OK).git pull origin main.npm install --production.--skip-migrate).pm2 reload ecosystem.config.js (zero-downtime, cluster)./health; on failure, git reset --hard $LAST_GOOD and reload.pm2 reload ecosystem.config.js — reload after a manual pull.pm2 logs tree-backend — tail logs.scripts/rollback.sh — restore .last_good_commit.scripts/backup_db.sh — runs nightly via cron.Tests are plain Node scripts under tests/. There is no test framework; each
script runs assertions, prints, and exits non-zero on failure.
npm test # intent classification + sql validation
npm run test:integration # chat integration (text-to-sql end-to-end)
npm run test:api # /api routes (smoke)
npm run test:regression # full regression against prod
npm run test:regression:local
npm run test:all # regression + api + securityAudit
tests/regression.test.js reads
TEST_BASE_URL, TEST_ADMIN_USER/PASS, TEST_SURVEY_USER/PASS.
交接與技術文件統一維護在 frontend repo 的 docs/(單一正本)。本 repo 的 docs/README.md 為指標頁(含可點擊連結)。
| 主題 | 文件 |
|---|---|
| 文件總入口 | frontend docs/README.md |
| 系統總覽、本機啟動、測試 | HANDOFF.md |
| 架構、API、模組指南 | ARCHITECTURE.md, API_REFERENCE.md, CODEBASE_INVENTORY.md |
| Git / PR / CI | DEVELOPMENT_WORKFLOW.md |
| Ubuntu 主機從零部署 | LAB_DEPLOYMENT_GUIDE.md |
| 金鑰與環境設定 | HANDOFF_SECRETS_CHECKLIST.md |
| 物種 / PlantNet | SPECIES_AND_PLANTNET.md |
| 實驗功能 | EXPERIMENTAL_FEATURES.md |
| 本 repo 程式目錄 | docs/SOURCE_LAYOUT.md |
後端架構、API 清單、資料表、背景工作見本檔(上方各節)。
MIT — see LICENSE. Original development and primary maintenance by KyleliuNDHU; see AUTHORS.md.
399 commits
1 commits
JavaScript
67.9%
Python
27.9%
PLpgSQL
1.4%
Shell
1.4%