feat: LICA 部门独立实例 — 组织隔离与端口/标识改造

派生自 IRIS 实例的 feature/ai-audit-update @ 192c8ee,在同一台机器上独立运行。

隔离机制(开关集中在 app/core/config.py 的 ORG_DEPARTMENT / MATERIAL_CATEGORY_PREFIX):
- 登录:sys_user 查询增加 department 条件,非本部门账号一律 401
- 人员列表:服务端钉死部门、忽略客户端传参;删除「异常退回全表」的降级分支
- 物料:groups 与 items 都增加 category LIKE 'LICA/%' 前缀过滤
- 人员操作统计:把硬编码的 department='IRIS' 改为配置项

物料为什么用前缀而不是 LIKE '%LICA%':
MOM 里存在 171 条 IRIS/成品/LICA/...(无人机/野外便携/高塔监测等),
模糊匹配会把这些 IRIS 物料漏给 LICA。实测前缀匹配命中 795 条 / 5 个分组。

部署隔离:
- 端口 8030/8031/8032,容器名 lica_*,卷 lica_pgdata(与 IRIS 完全独立)
- 服务名改为 lica_backend,避免在 projects_default 网络上与 IRIS 的 backend
  重名 —— 否则将来任何一方写 http://backend:8000 会随机打到另一个部门
- SECRET_KEY 重新生成:实测两边 token 互不通用(双向 401)

客户端标识(不改会导致两个部门的客户端互相覆盖):
- Tauri identifier 改 com.lica.production(否则桌面端互相覆盖安装,且共用
  WebView 数据目录会让 track_admin_token 串号)
- uni-app appid 改 __UNI__D2F4A19(否则同机 APK 互相覆盖、wgt 热更新串号)
- uni-app 地址端口 8011 → 8031(收敛在 utils/config.js 单一来源)
- sync-watch.sh 的 DST 指向 LICA 专属 HBuilderX 目录(否则会把源码灌进 IRIS 工程)

排除项:未复制 deploy.sh / deploy_full.sh / docker-compose.prod.yml ——
它们写死了 IRIS 的生产服务器,误跑会覆盖线上系统。
This commit is contained in:
2026-09-21 16:10:52 +08:00
parent 3286a11bc7
commit 347b7b6a68
15 changed files with 237 additions and 110 deletions

View File

@ -2,6 +2,30 @@
本仓库(Track 生产流转系统)的工作笔记。仅在验证过之后才写入,避免传谣。 本仓库(Track 生产流转系统)的工作笔记。仅在验证过之后才写入,避免传谣。
> ## ⚠️ 本仓库是 **LICA 部门实例**
>
> 派生自 IRIS 实例(`git.iris-rs.cn/duxingchen/track.git` 的
> `feature/ai-audit-update` 分支 @ `192c8ee`)。两套系统在**同一台机器**上并行
> 运行、共用 MOM 主数据,但人员、物料、业务数据**完全隔离**。
>
> | | IRIS 实例 | LICA 实例(本仓库) |
> |---|---|---|
> | 代码目录 | `/home/yueli/track` | `/home/yueli/track-lica` |
> | 容器 | `track_db` / `track_backend` / `track_frontend` | `lica_db` / `lica_backend` / `lica_frontend` |
> | compose 项目 | `track` | `track-lica` |
> | PC 管理端 | 8010 | **8030** |
> | 后端 API | 8011 | **8031** |
> | 数据库 | 8012 | **8032** |
> | 数据卷 | `track_pgdata` | `lica_pgdata` |
> | 部门过滤 | `IRIS` | `LICA` |
>
> **两条红线:**
> 1. **永远不要在 `/home/yueli/track` 目录下执行 `docker compose down -v`** ——
> 那会删掉 IRIS 的生产数据。两个卷相互独立,在 `track-lica` 下执行不影响 IRIS。
> 2. 改后端地址时别只改一半:PC 端走 `docker-compose.yml` 的 `CORS_ORIGINS`,
> 移动端走 `track-uniapp/src/utils/config.js`(uni-app 地址的**唯一来源**)
> 和 `track-uniapp/.env`。只改一处会出现「接口通了但图片/上传 404」。
## 架构速览 ## 架构速览
- `backend/` FastAPI + SQLAlchemy 2.x(async) + Alembic,PostgreSQL。 - `backend/` FastAPI + SQLAlchemy 2.x(async) + Alembic,PostgreSQL。
@ -11,6 +35,19 @@
`sys_user.username` 存 `"真实姓名/登录账号"`,`login()` 用 `sys_user.username` 存 `"真实姓名/登录账号"`,`login()` 用
`WHERE username LIKE '%/<账号>'` 匹配,`display_name` 由 `/` 拆解得到。 `WHERE username LIKE '%/<账号>'` 匹配,`display_name` 由 `/` 拆解得到。
MOM 连接配置在 `app/core/mom_database.py`(同步 psycopg2 引擎)。 MOM 连接配置在 `app/core/mom_database.py`(同步 psycopg2 引擎)。
- **组织隔离**:本实例只服务 LICA 部门,唯一开关是 `app/core/config.py` 的
`ORG_DEPARTMENT`(对应 MOM `sys_user.department`)与
`MATERIAL_CATEGORY_PREFIX`(对应 MOM `material_base.category` 前缀)。
过滤点共 4 处:登录 `auth_service.login`、人员列表 `endpoints/users.py`、
物料 `endpoints/materials.py` 的 `groups`/`items`、
人员操作统计 `dashboard_service.get_user_operations`。
- 人员与物料都是**服务端钉死**部门,客户端传什么部门参数都不采纳
(这样 uni-app 里写死的 `dept` 不会造成跨部门影响)。
- 刻意**不做**「查询失败退回全表」的降级 —— 那是跨部门数据泄漏,
宁可查不出,不可查过头。
- ⚠️ 物料必须用前缀 `category LIKE 'LICA/%'`,**不能用** `ILIKE '%LICA%'`:
MOM 里存在 171 条 `IRIS/成品/LICA/...`(无人机/野外便携/高塔监测等),
模糊匹配会把 IRIS 的物料漏给 LICA。已实测前缀匹配命中 795 条 / 5 个分组。
## 本地起环境(关键,踩过的坑都在这) ## 本地起环境(关键,踩过的坑都在这)
@ -19,16 +56,20 @@
然后 `sudo -n pg_ctlcluster 17 main start`。 然后 `sudo -n pg_ctlcluster 17 main start`。
本仓库不使用 pgvector,无需额外扩展。 本仓库不使用 pgvector,无需额外扩展。
2. **数据库端口与生产默认值不同**,必须用环境变量覆盖: 2. **数据库端口与生产默认值不同**,必须用环境变量覆盖:
- `DATABASE_URL=postgresql+asyncpg://track:track_prod_2026@127.0.0.1:5432/track_production` - `DATABASE_URL=postgresql+asyncpg://lica:<密码>@127.0.0.1:8032/lica_production`
- `MOM_DB_HOST=127.0.0.1`、`MOM_DB_PORT=5432` - `MOM_DB_HOST=inventory_db`、`MOM_DB_PORT=5432`(容器内走 projects_default
网络的服务名;从宿主机连则用 `127.0.0.1:5435`)
- `SECRET_KEY=<≥32 字符>`:`DEBUG=false` 时配置项会**拒绝**默认 SECRET_KEY - `SECRET_KEY=<≥32 字符>`:`DEBUG=false` 时配置项会**拒绝**默认 SECRET_KEY
(见 `app/core/config.py` 的校验),不设会直接 import 失败。 (见 `app/core/config.py` 的校验),不设会直接 import 失败。
3. 迁移:`cd backend && python3 -m alembic upgrade head`(没有全局 `alembic` 命令, 3. 迁移:`cd backend && python3 -m alembic upgrade head`(没有全局 `alembic` 命令,
要用 `python3 -m alembic`)。校验纯 SQL 用 `alembic upgrade head --sql`。 要用 `python3 -m alembic`)。校验纯 SQL 用 `alembic upgrade head --sql`。
4. 前端 proxy 指向 Docker 服务名 `backend:8000`。本机跑要么把 4. 前端 proxy 指向 Docker 服务名 `lica_backend:8000`(本实例的服务名**刻意不叫**
`127.0.0.1 backend` 写进 `/etc/hosts`,要么直接给 `backend`,避免在 `projects_default` 网络上与 IRIS 实例重名,否则将来任何一方
写 `http://backend:8000` 会随机打到另一个部门)。本机跑要么把
`127.0.0.1 lica_backend` 写进 `/etc/hosts`,要么直接给
`VITE_API_BASE_URL=http://localhost:<port>/api/v1` 绕过 proxy。 `VITE_API_BASE_URL=http://localhost:<port>/api/v1` 绕过 proxy。
注意 dev server 由 `basicSsl` 起 HTTPS,跨域需要后端 注意 dev server 由 `basicSsl` 起 HTTPS —— **必须用 `https://` 访问,
用 `http://` 会直接连不上(curl 报 000)**。跨域需要后端
`CORS_ORIGINS` 加上 `https://localhost:1420`。 `CORS_ORIGINS` 加上 `https://localhost:1420`。
## 测试的坑(重要) ## 测试的坑(重要)
@ -40,7 +81,7 @@
并在单个 `asyncio.run()` 里跑完全部请求。生产 uvicorn 单循环无此问题。 并在单个 `asyncio.run()` 里跑完全部请求。生产 uvicorn 单循环无此问题。
- 仓库目前**没有** pytest 基建,也没有前端测试脚本。 - 仓库目前**没有** pytest 基建,也没有前端测试脚本。
## 已知的待修问题(截至 1.0应用 分支) ## 已知的待修问题(继承自 IRIS 侧 `192c8ee`,本仓库**未修**)
- **读接口大面积未鉴权**(已实测,非推测):无 token 直接 200 的包括 - **读接口大面积未鉴权**(已实测,非推测):无 token 直接 200 的包括
`/api/v1/users/`、全部 `/api/v1/dashboard/*`(含 `/api/v1/users/`、全部 `/api/v1/dashboard/*`(含
@ -48,6 +89,15 @@
`/api/v1/analytics/*`、`/api/v1/screen/*`、`/api/v1/orders/`。 `/api/v1/analytics/*`、`/api/v1/screen/*`、`/api/v1/orders/`。
写操作和 `/api/v1/tasks`、`/api/v1/products` 是有鉴权的。 写操作和 `/api/v1/tasks`、`/api/v1/products` 是有鉴权的。
新增接口请统一用 `app/core/deps.py` 的 `require_admin` / `require_roles`。 新增接口请统一用 `app/core/deps.py` 的 `require_admin` / `require_roles`。
- **对 LICA 的影响面**:`/api/v1/users/` 虽无鉴权,但部门过滤在 SQL 层钉死,
实测无 token 也只能拿到 19 个 LICA 人员,**不会泄露 IRIS 的人**;
其余匿名接口读的是本实例自己的库,跨不到 IRIS 库。真正的风险是
「本部门内部」的越权(如普通操作员能看管理端统计),不是跨部门。
- `AdminLayout` 只判登录不判角色:LICA 的 15 个 `INBOUND` 账号能进 PC 管理端。
- `dashboard_service.py` 查 tasks 时用**小写** `pending/in_progress/completed`,
而 `Task.status` 实际存大写(`PENDING`/`WIP`/`COMPLETED`),
导致管理端「待接收/进行中/已完成」三个数字恒为 0。
(注意 `Product.status` 确实是小写,修的时候别一起改。)
- 「管理员角色」这份规则此前散在 4 处(后端 `task_service`、`products.py` 内联、 - 「管理员角色」这份规则此前散在 4 处(后端 `task_service`、`products.py` 内联、
前端 `constants/task.ts`、`AdminProductsPage` 内联),已因此出过事故。 前端 `constants/task.ts`、`AdminProductsPage` 内联),已因此出过事故。
**后端唯一事实来源是 `app/core/roles.py`,前端用 `constants/task.ts::isAdminRole`。** **后端唯一事实来源是 `app/core/roles.py`,前端用 `constants/task.ts::isAdminRole`。**

View File

@ -1,10 +1,26 @@
DATABASE_URL=postgresql+asyncpg://track:track_prod_2026@localhost:5433/track_production # ============================================================
# LICA 实例 — 本地裸跑后端时的配置示例
# 容器部署(docker compose)无需此文件,所有变量由 docker-compose.yml 注入。
# 复制为 .env 后按需修改;.env 已被 .gitignore 忽略,不会提交。
# ============================================================
# 本机直连 LICA 库(容器内用的是 lica_db,端口 5432)
DATABASE_URL=postgresql+asyncpg://lica:lica_dev_password@localhost:8032/lica_production
SECRET_KEY=change-me-to-a-random-secret-key-in-production SECRET_KEY=change-me-to-a-random-secret-key-in-production
ACCESS_TOKEN_EXPIRE_MINUTES=30 ACCESS_TOKEN_EXPIRE_MINUTES=30
DEBUG=true DEBUG=true
CORS_ORIGINS='["http://localhost:1420", "tauri://localhost"]' CORS_ORIGINS='["http://localhost:8030", "tauri://localhost"]'
# 日志:LOG_JSON=true 输出单行 JSON(便于采集),本地调试可设 false 换可读格式 # ---- 组织隔离(本实例唯一的部门开关)----
ORG_DEPARTMENT=LICA
MATERIAL_CATEGORY_PREFIX=LICA/
# ---- MOM 老系统库(只读,用于登录校验/人员/物料)----
# 容器内用容器名 inventory_db:5432;本机裸跑走宿主机映射端口 5435
MOM_DB_HOST=localhost
MOM_DB_PORT=5435
# ---- 日志:LOG_JSON=true 输出单行 JSON(便于采集),本地调试可设 false 换可读格式 ----
LOG_LEVEL=INFO LOG_LEVEL=INFO
LOG_JSON=true LOG_JSON=true

View File

@ -1,12 +1,24 @@
"""物料选择器 — 读 MOM material_base,按成品/半成品 category 手风琴分组""" """物料选择器 — 读 MOM material_base,按 category 手风琴分组(仅本部门)"""
from fastapi import APIRouter, Query, HTTPException, status, Depends from fastapi import APIRouter, Query, HTTPException, status, Depends
from pydantic import BaseModel from pydantic import BaseModel
from app.core.config import settings
from app.core.mom_database import MomSessionLocal from app.core.mom_database import MomSessionLocal
from app.services.auth_service import get_current_user from app.services.auth_service import get_current_user
from sqlalchemy import text from sqlalchemy import text
router = APIRouter(prefix="/materials", tags=["物料选择"]) router = APIRouter(prefix="/materials", tags=["物料选择"])
# 部门隔离:只放行本部门 category 前缀(LICA/…)。
#
# ⚠️ 绝不能用 ILIKE '%LICA%' 之类的模糊匹配:MOM 里存在 171 条
# `IRIS/成品/LICA/...`(无人机 38 / 野外便携 59 / 高塔监测 30 / 实验室内 34
# 等),模糊匹配会把 IRIS 的物料漏给 LICA。
# 实测 `category LIKE 'LICA/%'` → 795 条 / 5 个分组。
#
# 另注:LICA 的分类是 `LICA/<中文>` 两段式,不含「成品/半成品」字样,
# 所以不能用类型词过滤(那会返回 0 条)。
CATEGORY_PREFIX_LIKE = f"{settings.MATERIAL_CATEGORY_PREFIX}%"
# ============================================================ # ============================================================
# 响应模型 # 响应模型
@ -38,7 +50,7 @@ def get_material_groups(
): ):
""" """
按 category 分组汇总,前端渲染手风琴外层。 按 category 分组汇总,前端渲染手风琴外层。
只返回成品/半成品分类。 只返回本部门(ORG_DEPARTMENT)名下的分类。
""" """
db = MomSessionLocal() db = MomSessionLocal()
try: try:
@ -47,20 +59,24 @@ def get_material_groups(
SELECT category, COUNT(*) AS count SELECT category, COUNT(*) AS count
FROM material_base FROM material_base
WHERE is_enabled = TRUE WHERE is_enabled = TRUE
AND category LIKE :cat_prefix
AND (name ILIKE :kw OR spec_model ILIKE :kw) AND (name ILIKE :kw OR spec_model ILIKE :kw)
GROUP BY category GROUP BY category
ORDER BY category ORDER BY category
""") """)
result = db.execute(sql, {"kw": f"%{keyword.strip()}%"}) result = db.execute(
sql, {"cat_prefix": CATEGORY_PREFIX_LIKE, "kw": f"%{keyword.strip()}%"}
)
else: else:
sql = text(""" sql = text("""
SELECT category, COUNT(*) AS count SELECT category, COUNT(*) AS count
FROM material_base FROM material_base
WHERE is_enabled = TRUE WHERE is_enabled = TRUE
AND category LIKE :cat_prefix
GROUP BY category GROUP BY category
ORDER BY category ORDER BY category
""") """)
result = db.execute(sql) result = db.execute(sql, {"cat_prefix": CATEGORY_PREFIX_LIKE})
rows = result.fetchall() rows = result.fetchall()
return [MaterialGroup(category=row.category, count=row.count) for row in rows] return [MaterialGroup(category=row.category, count=row.count) for row in rows]
@ -82,6 +98,10 @@ def get_material_items(
): ):
""" """
获取指定 category 下的物料条目,前端展开手风琴时懒加载。 获取指定 category 下的物料条目,前端展开手风琴时懒加载。
这里同样要加部门前缀条件(纵深防御):`category` 完全由客户端提供,
只靠 `category = :cat` 精确匹配的话,构造一个跨部门的 category 就能
把别的部门的物料捞出来。
""" """
db = MomSessionLocal() db = MomSessionLocal()
try: try:
@ -91,13 +111,20 @@ def get_material_items(
COALESCE(unit, '') AS unit, is_enabled COALESCE(unit, '') AS unit, is_enabled
FROM material_base FROM material_base
WHERE is_enabled = TRUE WHERE is_enabled = TRUE
AND category LIKE :cat_prefix
AND category = :cat AND category = :cat
AND (name ILIKE :kw OR spec_model ILIKE :kw) AND (name ILIKE :kw OR spec_model ILIKE :kw)
ORDER BY name ORDER BY name
LIMIT :lim LIMIT :lim
""") """)
result = db.execute( result = db.execute(
sql, {"cat": category, "kw": f"%{keyword.strip()}%", "lim": limit} sql,
{
"cat_prefix": CATEGORY_PREFIX_LIKE,
"cat": category,
"kw": f"%{keyword.strip()}%",
"lim": limit,
},
) )
else: else:
sql = text(""" sql = text("""
@ -105,11 +132,14 @@ def get_material_items(
COALESCE(unit, '') AS unit, is_enabled COALESCE(unit, '') AS unit, is_enabled
FROM material_base FROM material_base
WHERE is_enabled = TRUE WHERE is_enabled = TRUE
AND category LIKE :cat_prefix
AND category = :cat AND category = :cat
ORDER BY name ORDER BY name
LIMIT :lim LIMIT :lim
""") """)
result = db.execute(sql, {"cat": category, "lim": limit}) result = db.execute(
sql, {"cat_prefix": CATEGORY_PREFIX_LIKE, "cat": category, "lim": limit}
)
rows = result.fetchall() rows = result.fetchall()
return [ return [

View File

@ -1,6 +1,7 @@
"""用户列表 — 对接 MOM sys_user""" """用户列表 — 对接 MOM sys_user"""
from fastapi import APIRouter, Query, HTTPException, status from fastapi import APIRouter, Query, HTTPException, status
from pydantic import BaseModel from pydantic import BaseModel
from app.core.config import settings
from app.core.mom_database import MomSessionLocal from app.core.mom_database import MomSessionLocal
from sqlalchemy import text from sqlalchemy import text
@ -20,42 +21,31 @@ class UserOption(BaseModel):
def list_users( def list_users(
keyword: str = Query("", description="按用户名/姓名模糊搜索"), keyword: str = Query("", description="按用户名/姓名模糊搜索"),
limit: int = Query(100, ge=1, le=500), limit: int = Query(100, ge=1, le=500),
dept: str = Query("IRIS", description="部门过滤"), dept: str = Query("", description="已废弃:部门由服务端按 ORG_DEPARTMENT 钉死,此参数不参与过滤"),
): ):
"""获取 MOM 系统用户列表,默认只返回 IRIS 部门""" """获取 MOM 系统用户列表,只返回本部门(ORG_DEPARTMENT)人员"""
# 部门隔离由服务端钉死:无论客户端传什么(含旧版 App 里写死的 dept=IRIS),
# 一律只按 ORG_DEPARTMENT 过滤。这样同一份 App 源码不必按部门分叉。
#
# 这里刻意【不做】「查询异常就退回全表」的降级:那等于把另一个部门的人员
# 名单也列出来供本部门挑选,是跨部门数据泄漏。查不出来就报错 ——
# 宁可查不出,不可查过头。
db = MomSessionLocal() db = MomSessionLocal()
try: try:
# 尝试按部门过滤;若 sys_user 无 department 列则降级全量查询 base_sql = """
try: SELECT id, username,
base_sql = """ SPLIT_PART(username, '/', 1) AS full_name,
SELECT id, username, COALESCE(department, '') AS department
SPLIT_PART(username, '/', 1) AS full_name, FROM sys_user
COALESCE(department, '') AS department WHERE department = :dept
FROM sys_user """
WHERE department = :dept params = {"dept": settings.ORG_DEPARTMENT, "lim": limit}
""" if keyword.strip():
params = {"dept": dept, "lim": limit} sql = text(base_sql + " AND username ILIKE :kw ORDER BY username LIMIT :lim")
if keyword.strip(): params["kw"] = f"%{keyword.strip()}%"
sql = text(base_sql + " AND username ILIKE :kw ORDER BY username LIMIT :lim") else:
params["kw"] = f"%{keyword.strip()}%" sql = text(base_sql + " ORDER BY username LIMIT :lim")
else: rows = db.execute(sql, params).fetchall()
sql = text(base_sql + " ORDER BY username LIMIT :lim")
rows = db.execute(sql, params).fetchall()
except Exception:
# 降级:不使用 department 列过滤
fallback_sql = """
SELECT id, username,
SPLIT_PART(username, '/', 1) AS full_name,
'' AS department
FROM sys_user
"""
params = {"lim": limit}
if keyword.strip():
sql = text(fallback_sql + " WHERE username ILIKE :kw ORDER BY username LIMIT :lim")
params["kw"] = f"%{keyword.strip()}%"
else:
sql = text(fallback_sql + " ORDER BY username LIMIT :lim")
rows = db.execute(sql, params).fetchall()
return [ return [
UserOption( UserOption(
@ -66,6 +56,8 @@ def list_users(
) )
for row in rows for row in rows
] ]
except HTTPException:
raise
except Exception as e: except Exception as e:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY, status_code=status.HTTP_502_BAD_GATEWAY,

View File

@ -33,6 +33,12 @@ class Settings(BaseSettings):
# ---- MOM 仓储系统回调 Webhook(Track 作为接收方,验签用) ---- # ---- MOM 仓储系统回调 Webhook(Track 作为接收方,验签用) ----
TRACK_WEBHOOK_KEY: str | None = None # MOM 回调 POST 时 Header X-API-Key 须等于此值 TRACK_WEBHOOK_KEY: str | None = None # MOM 回调 POST 时 Header X-API-Key 须等于此值
# ---- 组织隔离 ----
# 同一套代码部署给不同部门时,只需改这两个值(+ compose 里的项目名/容器名/端口)。
# 全仓库的部门过滤点只有三处 SQL:登录、人员列表、物料选择器,全部引用这里。
ORG_DEPARTMENT: str = "LICA" # MOM sys_user.department 的取值
MATERIAL_CATEGORY_PREFIX: str = "LICA/" # MOM material_base.category 的部门前缀
@property @property
def CORS_ORIGINS_LIST(self) -> list[str]: def CORS_ORIGINS_LIST(self) -> list[str]:
"""将 JSON 字符串解析为 Python list,供 CORSMiddleware 使用""" """将 JSON 字符串解析为 Python list,供 CORSMiddleware 使用"""

View File

@ -25,14 +25,19 @@ def login(username: str, password: str) -> LoginResponse:
db = MomSessionLocal() db = MomSessionLocal()
try: try:
# 1. 普通用户:LIKE '%/username' 模糊匹配 MOM sys_user 表 # 1. 普通用户:LIKE '%/username' 模糊匹配 MOM sys_user 表
# 并且只允许本部门(settings.ORG_DEPARTMENT)的人登录 —— IRIS 与 LICA
# 共用同一个 MOM 库,这里不加部门条件的话,另一个部门的人也能登进来。
# 不匹配时统一报「用户名或密码错误」,不区分「账号不存在」与「存在但
# 不属于本部门」,避免给账号探测者提供线索。
from sqlalchemy import text from sqlalchemy import text
result = db.execute( result = db.execute(
text( text(
"SELECT id, username, department, role, password_hash " "SELECT id, username, department, role, password_hash "
"FROM sys_user " "FROM sys_user "
"WHERE username LIKE :pattern" "WHERE username LIKE :pattern "
" AND department = :dept"
), ),
{"pattern": f"%/{username}"}, {"pattern": f"%/{username}", "dept": settings.ORG_DEPARTMENT},
) )
row = result.fetchone() row = result.fetchone()

View File

@ -4,6 +4,7 @@ from sqlalchemy import select, func, or_
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel from pydantic import BaseModel
from app.core.config import settings
from app.core.lifecycle import normalize_after_sales_step from app.core.lifecycle import normalize_after_sales_step
@ -791,8 +792,8 @@ async def get_user_operations(
) -> list[UserOperation]: ) -> list[UserOperation]:
"""上帝视角 — 统计**全部人员**的操作次数(按时段过滤)。 """上帝视角 — 统计**全部人员**的操作次数(按时段过滤)。
返回所有 IRIS 部门人员(该时段无操作的计 0),并追加有操作记录但不在 返回本部门(ORG_DEPARTMENT)全部人员(该时段无操作的计 0),并追加有操作
人员清单中的账号(如历史/已离职)。 记录但不在人员清单中的账号(如历史/已离职)。
操作口径(均不修改数据库): 操作口径(均不修改数据库):
- 接收: task_logs.action_type='receive'(按操作人) - 接收: task_logs.action_type='receive'(按操作人)
@ -840,20 +841,20 @@ async def get_user_operations(
rcd_rows = (await db.execute(rcd_stmt)).all() rcd_rows = (await db.execute(rcd_stmt)).all()
record_map = {r[0]: r[1] or 0 for r in rcd_rows} record_map = {r[0]: r[1] or 0 for r in rcd_rows}
# ── 3. 获取全部 MOM 用户清单(IRIS 部门)── # ── 3. 获取全部 MOM 用户清单(本部门)──
# 部门由 ORG_DEPARTMENT 钉死;刻意不保留「查询失败退回全表」的降级 ——
# 那会把另一个部门的人员也列进本部门的统计表。
users: list[dict] = [] users: list[dict] = []
try: try:
dbm = MomSessionLocal() dbm = MomSessionLocal()
try: try:
rows = dbm.execute(text(""" rows = dbm.execute(
SELECT username, SPLIT_PART(username, '/', 1) AS full_name text("""
FROM sys_user WHERE department = 'IRIS' SELECT username, SPLIT_PART(username, '/', 1) AS full_name
""")).fetchall() FROM sys_user WHERE department = :dept
except Exception: """),
rows = dbm.execute(text(""" {"dept": settings.ORG_DEPARTMENT},
SELECT username, SPLIT_PART(username, '/', 1) AS full_name ).fetchall()
FROM sys_user
""")).fetchall()
finally: finally:
dbm.close() dbm.close()
for row in rows: for row in rows:

View File

@ -1,58 +1,76 @@
# ============================================================ # ============================================================
# 生产流转管理系统 — 全栈 Docker 热更新开发环境 # LICA 生产流转管理系统 — 独立实例(与 IRIS 版同机共存)
# 启动: docker compose up -d --build # 启动: docker compose up -d --build
# 停止: docker compose down # 停止: docker compose down
# 日志: docker compose logs -f # 日志: docker compose logs -f
#
# ⚠️ 首次部署必须先跑数据库迁移,否则业务接口全部 500:
# docker compose exec lica_backend python -m alembic upgrade head
# ============================================================ # ============================================================
name: track-lica
services: services:
# ============================================================ # ============================================================
# PostgreSQL 15 + pgvector # PostgreSQL 15 + pgvector — LICA 独立库
# ============================================================ # ============================================================
db: db:
image: pgvector/pgvector:pg15 image: pgvector/pgvector:pg15
container_name: track_db container_name: lica_db
restart: unless-stopped restart: unless-stopped
environment: environment:
POSTGRES_USER: track POSTGRES_USER: lica
POSTGRES_PASSWORD: track_prod_2026 POSTGRES_PASSWORD: bc7e44ba7aa424b5a1d95a53dec824fc
POSTGRES_DB: track_production POSTGRES_DB: lica_production
ports: ports:
- "8012:5432" - "8032:5432"
volumes: volumes:
- track_pgdata:/var/lib/postgresql/data # ⚠️ 绝不能与 IRIS 的 track_pgdata 共用,否则两个部门写同一个库
- lica_pgdata:/var/lib/postgresql/data
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U track -d track_production"] test: ["CMD-SHELL", "pg_isready -U lica -d lica_production"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
# ============================================================ # ============================================================
# FastAPI 后端 — --reload 热更新 # FastAPI 后端 — --reload 热更新
# 服务名用 lica_backend 而非 backend:服务名会作为网络别名注册到
# projects_default,与 IRIS 实例的 backend 重名会导致将来任何一方
# 写 http://backend:8000 时随机打到另一个部门。
# ============================================================ # ============================================================
backend: lica_backend:
build: build:
context: ./backend context: ./backend
dockerfile: Dockerfile.dev dockerfile: Dockerfile.dev
container_name: track_backend container_name: lica_backend
restart: unless-stopped restart: unless-stopped
environment: environment:
# 🚀 track 主库用容器名 track_db,避免与 MOM 的 inventory_db(别名 db) 在同一网络下 DNS 解析冲突 # ⚠️ 主机名必须是 lica_db,绝不能写 db ——
DATABASE_URL: postgresql+asyncpg://track:track_prod_2026@track_db:5432/track_production # MOM 的 compose 服务名就叫 db,在 projects_default 网络上
SECRET_KEY: change-me-in-production # 会把本后端解析到 MOM 生产库并写入。
DATABASE_URL: postgresql+asyncpg://lica:bc7e44ba7aa424b5a1d95a53dec824fc@lica_db:5432/lica_production
# ⚠️ 必须与 IRIS 不同:get_current_user 只验签名不查库,
# 两边共用密钥则 IRIS 签发的 token 可直接访问 LICA 接口。
SECRET_KEY: aQy1zyEJY50OJlYrZ2OkNgQN3N46F4bvjYVdva0y4N3wpfaZotf9NKBTZ0CTDeLD
DEBUG: "true" DEBUG: "true"
# 8010 = PC 管理端;5173/8020 = track-uniapp 的 H5 调试服务 # ---- 组织隔离(本实例唯一的部门开关,供登录/人员/物料三处 SQL 引用)----
ORG_DEPARTMENT: LICA
MATERIAL_CATEGORY_PREFIX: "LICA/"
# 8030 = PC 管理端;5173/8020 = track-uniapp 的 H5 调试服务
# (HBuilderX「运行到浏览器」起在 5173,直接跑 vite 按 track-uniapp/vite.config.ts 是 8020)。 # (HBuilderX「运行到浏览器」起在 5173,直接跑 vite 按 track-uniapp/vite.config.ts 是 8020)。
# ⚠️ 漏了 uniapp 的来源时,浏览器会在 CORS 预检阶段就掐掉请求, # ⚠️ 漏了 uniapp 的来源时,浏览器会在 CORS 预检阶段就掐掉请求,
# 表现是「点登录没反应」,且控制台只报一句笼统的 404/400。 # 表现是「点登录没反应」,且控制台只报一句笼统的 404/400。
CORS_ORIGINS: '["http://localhost:8010","https://localhost:8010","http://192.168.9.80:8010","https://192.168.9.80:8010","http://localhost:5173","https://localhost:5173","http://192.168.9.80:5173","https://192.168.9.80:5173","http://localhost:8020","https://localhost:8020","tauri://localhost"]' CORS_ORIGINS: '["http://localhost:8030","https://localhost:8030","http://192.168.9.80:8030","https://192.168.9.80:8030","http://localhost:5173","https://localhost:5173","http://192.168.9.80:5173","https://192.168.9.80:5173","http://localhost:8020","https://localhost:8020","tauri://localhost"]'
# 🚀 MOM 老系统数据库 — 通过容器名解析(projects_default 网络内 DNS),不再写死 IP # MOM 老系统数据库 — 通过容器名解析(projects_default 网络内 DNS),不要写死 IP
MOM_DB_HOST: inventory_db MOM_DB_HOST: inventory_db
MOM_DB_PORT: "5432" MOM_DB_PORT: "5432"
# 🚀 MOM 仓储系统回调 Webhook 验签 Key(与 MOM 侧 TRACK_WEBHOOK_KEY 保持一致) # MOM 回调验签 Key — 暂与 IRIS 相同:MOM 侧目前只有单个 TRACK_WEBHOOK_KEY,
# 且回调地址写死指向 IRIS 的 track_backend。待 MOM 支持按部门双推后,
# 再决定是否给 LICA 单独签发。(在此之前 LICA 收不到出入库联动)
TRACK_WEBHOOK_KEY: 2ce5fedb48fde3fd7e0abf67472a5027b03e9ae6f19cf768 TRACK_WEBHOOK_KEY: 2ce5fedb48fde3fd7e0abf67472a5027b03e9ae6f19cf768
ports: ports:
- "8011:8000" - "8031:8000"
volumes: volumes:
- ./backend:/app - ./backend:/app
depends_on: depends_on:
@ -69,23 +87,23 @@ services:
build: build:
context: ./frontend context: ./frontend
dockerfile: Dockerfile.dev dockerfile: Dockerfile.dev
container_name: track_frontend container_name: lica_frontend
restart: unless-stopped restart: unless-stopped
environment: environment:
VITE_API_BASE_URL: /api/v1 VITE_API_BASE_URL: /api/v1
ports: ports:
- "8010:1420" - "8030:1420"
volumes: volumes:
- ./frontend:/app - ./frontend:/app
- /app/node_modules # 匿名卷,不覆盖容器里的 node_modules - /app/node_modules # 匿名卷,不覆盖容器里的 node_modules
depends_on: depends_on:
- backend - lica_backend
networks: networks:
mom_net: mom_net:
external: true external: true
name: projects_default name: projects_default # MOM 系统所在网络(IRIS 实例也在此网络上)
volumes: volumes:
track_pgdata: lica_pgdata:
name: track_pgdata name: lica_pgdata

View File

@ -1,8 +1,8 @@
{ {
"$schema": "https://raw.githubusercontent.com/nickolay/tauri-docs/refs/heads/v2/src/content/docs/_schema/config.schema.json", "$schema": "https://raw.githubusercontent.com/nickolay/tauri-docs/refs/heads/v2/src/content/docs/_schema/config.schema.json",
"productName": "track", "productName": "track-lica",
"version": "0.1.0", "version": "0.1.0",
"identifier": "com.track.production", "identifier": "com.lica.production",
"build": { "build": {
"frontendDist": "../dist", "frontendDist": "../dist",
"devUrl": "http://localhost:1420", "devUrl": "http://localhost:1420",
@ -10,10 +10,10 @@
"beforeBuildCommand": "npm run build" "beforeBuildCommand": "npm run build"
}, },
"app": { "app": {
"title": "生产流转", "title": "生产流转 · LICA",
"windows": [ "windows": [
{ {
"title": "工厂生产流转管理系统", "title": "工厂生产流转管理系统 · LICA",
"width": 390, "width": 390,
"height": 844, "height": 844,
"resizable": true, "resizable": true,

View File

@ -65,7 +65,7 @@ export default function AdminLoginPage() {
type="text" type="text"
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
placeholder="如 IRIS 或工号" placeholder="如 LICA 或工号"
className="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-100" className="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-100"
autoFocus autoFocus
/> />

View File

@ -39,7 +39,7 @@ export default defineConfig({
}, },
proxy: { proxy: {
"/api": { "/api": {
target: "http://backend:8000", // Docker 内部用服务名 target: "http://lica_backend:8000", // Docker 内部用服务名(LICA 实例)
changeOrigin: true, changeOrigin: true,
}, },
}, },

View File

@ -1,9 +1,9 @@
{ {
"name" : "Track", "name" : "Track-LICA",
"appid" : "__UNI__B572616", "appid" : "__UNI__D2F4A19",
"description" : "Track - 生产流转管理", "description" : "LICA - 生产流转管理",
"versionName" : "T1.0.8", "versionName" : "T1.0.0",
"versionCode" : 108, "versionCode" : 100,
"transformPx" : false, "transformPx" : false,
"vueVersion" : "3", "vueVersion" : "3",
"app-plus" : { "app-plus" : {

View File

@ -455,7 +455,10 @@ export default {
openEditProduct() { this.editForm = { order_no: this.product.order_no || "", external_serial: this.product.external_serial || "" }; this.editProductVisible = true; }, openEditProduct() { this.editForm = { order_no: this.product.order_no || "", external_serial: this.product.external_serial || "" }; this.editProductVisible = true; },
async doEditProduct() { this.editSaving = true; try { this.product = await patch(`/products/${this.product.id}`, { order_no: this.editForm.order_no.trim(), external_serial: this.editForm.external_serial.trim() }); uni.showToast({ title: "已保存", icon: "success" }); this.editProductVisible = false; } catch {} finally { this.editSaving = false; } }, async doEditProduct() { this.editSaving = true; try { this.product = await patch(`/products/${this.product.id}`, { order_no: this.editForm.order_no.trim(), external_serial: this.editForm.external_serial.trim() }); uni.showToast({ title: "已保存", icon: "success" }); this.editProductVisible = false; } catch {} finally { this.editSaving = false; } },
async loadUsers() { try { const res = await get("/users/", { limit: 200, dept: "IRIS" }); this.users = (res || []).filter(u => u.department === "IRIS"); this.userOptions = this.users.map(u => ({ id: u.username || u.id, name: u.full_name || u.username })); setUserNameMap(this.users); this.dictVersion += 1; } catch (e) { console.error('[loadUsers] 获取用户列表失败:', e); } }, // 部门隔离由后端按 ORG_DEPARTMENT 钉死,客户端不再传 dept、也不再二次过滤。
// 原先写死的 department === "IRIS" 会把服务端返回的 LICA 人员全部滤成空数组,
// 表现为「指派负责人」下拉框没有任何可选人员、无法建任务。
async loadUsers() { try { const res = await get("/users/", { limit: 200 }); this.users = res || []; this.userOptions = this.users.map(u => ({ id: u.username || u.id, name: u.full_name || u.username })); setUserNameMap(this.users); this.dictVersion += 1; } catch (e) { console.error('[loadUsers] 获取用户列表失败:', e); } },
loadCurrentUser() { try { let user = uni.getStorageSync("user"); if (typeof user === "string" && user) { try { user = JSON.parse(user); } catch (e) { user = null; } } if (user && typeof user === "object") { this.currentUser = user; this.currentUserId = String(user.id || ""); this.currentUsername = user.username || ""; this.currentUserRole = user.role || ""; } } catch {} }, loadCurrentUser() { try { let user = uni.getStorageSync("user"); if (typeof user === "string" && user) { try { user = JSON.parse(user); } catch (e) { user = null; } } if (user && typeof user === "object") { this.currentUser = user; this.currentUserId = String(user.id || ""); this.currentUsername = user.username || ""; this.currentUserRole = user.role || ""; } } catch {} },
onTaskNameChange(e) { const idx = e.detail.value; this.firstForm.taskNameIdx = idx; this.firstForm.task_name = this.availableTaskOptions[idx]; }, onTaskNameChange(e) { const idx = e.detail.value; this.firstForm.taskNameIdx = idx; this.firstForm.task_name = this.availableTaskOptions[idx]; },

View File

@ -4,19 +4,20 @@
* 此前 request.js 与 App.vue 各自硬编码了一份生产域名,改域名要改两处, * 此前 request.js 与 App.vue 各自硬编码了一份生产域名,改域名要改两处,
* 漏一处就会出现「接口通了但 OTA 检查更新接不上」这类极难排查的偏差, * 漏一处就会出现「接口通了但 OTA 检查更新接不上」这类极难排查的偏差,
* 故统一收口到本文件。新增环境地址一律加在这里,不要在业务代码里写死。 * 故统一收口到本文件。新增环境地址一律加在这里,不要在业务代码里写死。
*
* ⚠️ 本文件属于 **LICA 部门实例**,后端端口 8031。
* IRIS 实例用的是 8011 —— 两套系统是两个独立后端、独立数据库。
* 改这里之前先确认端口,填错会让 LICA 的 App 连到 IRIS 的后端,
* 表现为「能登录、能扫码,但看到的全是别的部门的单」。
*/ */
// 生产环境 API 地址 // 生产环境 API 地址
// // LICA 暂无独立域名,车间局域网内直连后端机器。
// ⚠️ HTTPS 说明(2026-09 实测):生产域名 track_back.iris-rs.cn 的 443 端口 // 待有专属域名/HTTPS 证书后,把下面地址整体替换即可,业务代码无需改动。
// 是通的、也确实配有证书,但证书【已过期】—— 现在切 https 会让 App 的 export const PROD_URL = "http://192.168.9.80:8031/api/v1";
// 全部请求直接失败。待运维完成证书续期后,只需把下面 PROD_URL 与
// LOCAL_URL 的协议头改成 https:// 即可,业务代码无需任何改动。
// 续期前请勿改动,保持 http:// 以保证可用性。
export const PROD_URL = "http://track_back.iris-rs.cn/api/v1";
// 本地开发地址 — 真机调试时指向开发机局域网 IP // 本地开发地址 — 真机调试时指向开发机局域网 IP
export const LOCAL_URL = "http://192.168.9.80:8011/api/v1"; export const LOCAL_URL = "http://192.168.9.80:8031/api/v1";
// 生产基础地址:供只认「生产环境」的场景直接引用 // 生产基础地址:供只认「生产环境」的场景直接引用
export const BASE_URL = PROD_URL; export const BASE_URL = PROD_URL;

View File

@ -2,7 +2,12 @@
# ============================================================ # ============================================================
# track-uniapp 源码 → HBuilderX 项目 同步 # track-uniapp 源码 → HBuilderX 项目 同步
# #
# 方向:单向 WSL track-uniapp/src/ → G:\Track\track-app\track # 方向:单向 WSL track-lica/track-uniapp/src/ → G:\Track\track-lica-app\track
#
# ⚠️ LICA 与 IRIS 是两套独立的 App 工程,DST 必须指向各自在 Windows 侧的
# HBuilderX 目录。若两边共用同一个 DST,本脚本会持续把 LICA 的源码灌进
# IRIS 的工程(而且带 --delete,会删掉对方的文件),因为带监听循环,
# 改一次就会反复覆盖。
# #
# ⚠️ 为什么是「src/ → 项目根」且单向(2026-09-15 修正): # ⚠️ 为什么是「src/ → 项目根」且单向(2026-09-15 修正):
# HBuilderX 是以 G:\Track\track-app\track 为项目根打开的(经典布局, # HBuilderX 是以 G:\Track\track-app\track 为项目根打开的(经典布局,
@ -24,8 +29,8 @@
# 停止: Ctrl+C # 停止: Ctrl+C
# ============================================================ # ============================================================
SRC="/home/yueli/track/track-uniapp/src" SRC="/home/yueli/track-lica/track-uniapp/src"
DST="/mnt/g/Track/track-app/track" DST="/mnt/g/Track/track-lica-app/track"
# ── 只在 Windows 侧存在、绝对不能被覆盖或删除的项 ── # ── 只在 Windows 侧存在、绝对不能被覆盖或删除的项 ──
EXCLUDES=( EXCLUDES=(