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

@ -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
ACCESS_TOKEN_EXPIRE_MINUTES=30
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_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 pydantic import BaseModel
from app.core.config import settings
from app.core.mom_database import MomSessionLocal
from app.services.auth_service import get_current_user
from sqlalchemy import text
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 分组汇总,前端渲染手风琴外层。
只返回成品/半成品分类。
只返回本部门ORG_DEPARTMENT名下的分类。
"""
db = MomSessionLocal()
try:
@ -47,20 +59,24 @@ def get_material_groups(
SELECT category, COUNT(*) AS count
FROM material_base
WHERE is_enabled = TRUE
AND category LIKE :cat_prefix
AND (name ILIKE :kw OR spec_model ILIKE :kw)
GROUP 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:
sql = text("""
SELECT category, COUNT(*) AS count
FROM material_base
WHERE is_enabled = TRUE
AND category LIKE :cat_prefix
GROUP BY category
ORDER BY category
""")
result = db.execute(sql)
result = db.execute(sql, {"cat_prefix": CATEGORY_PREFIX_LIKE})
rows = result.fetchall()
return [MaterialGroup(category=row.category, count=row.count) for row in rows]
@ -82,6 +98,10 @@ def get_material_items(
):
"""
获取指定 category 下的物料条目,前端展开手风琴时懒加载。
这里同样要加部门前缀条件(纵深防御):`category` 完全由客户端提供,
只靠 `category = :cat` 精确匹配的话,构造一个跨部门的 category 就能
把别的部门的物料捞出来。
"""
db = MomSessionLocal()
try:
@ -91,13 +111,20 @@ def get_material_items(
COALESCE(unit, '') AS unit, is_enabled
FROM material_base
WHERE is_enabled = TRUE
AND category LIKE :cat_prefix
AND category = :cat
AND (name ILIKE :kw OR spec_model ILIKE :kw)
ORDER BY name
LIMIT :lim
""")
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:
sql = text("""
@ -105,11 +132,14 @@ def get_material_items(
COALESCE(unit, '') AS unit, is_enabled
FROM material_base
WHERE is_enabled = TRUE
AND category LIKE :cat_prefix
AND category = :cat
ORDER BY name
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()
return [

View File

@ -1,6 +1,7 @@
"""用户列表 — 对接 MOM sys_user"""
from fastapi import APIRouter, Query, HTTPException, status
from pydantic import BaseModel
from app.core.config import settings
from app.core.mom_database import MomSessionLocal
from sqlalchemy import text
@ -20,42 +21,31 @@ class UserOption(BaseModel):
def list_users(
keyword: str = Query("", description="按用户名/姓名模糊搜索"),
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()
try:
# 尝试按部门过滤;若 sys_user 无 department 列则降级全量查询
try:
base_sql = """
SELECT id, username,
SPLIT_PART(username, '/', 1) AS full_name,
COALESCE(department, '') AS department
FROM sys_user
WHERE department = :dept
"""
params = {"dept": dept, "lim": limit}
if keyword.strip():
sql = text(base_sql + " AND username ILIKE :kw ORDER BY username LIMIT :lim")
params["kw"] = f"%{keyword.strip()}%"
else:
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()
base_sql = """
SELECT id, username,
SPLIT_PART(username, '/', 1) AS full_name,
COALESCE(department, '') AS department
FROM sys_user
WHERE department = :dept
"""
params = {"dept": settings.ORG_DEPARTMENT, "lim": limit}
if keyword.strip():
sql = text(base_sql + " AND username ILIKE :kw ORDER BY username LIMIT :lim")
params["kw"] = f"%{keyword.strip()}%"
else:
sql = text(base_sql + " ORDER BY username LIMIT :lim")
rows = db.execute(sql, params).fetchall()
return [
UserOption(
@ -66,6 +56,8 @@ def list_users(
)
for row in rows
]
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,

View File

@ -33,6 +33,12 @@ class Settings(BaseSettings):
# ---- MOM 仓储系统回调 WebhookTrack 作为接收方,验签用) ----
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
def CORS_ORIGINS_LIST(self) -> list[str]:
"""将 JSON 字符串解析为 Python list供 CORSMiddleware 使用"""

View File

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

View File

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