diff --git a/backend/app/api/v1/endpoints/products.py b/backend/app/api/v1/endpoints/products.py
index 878faa8..b756066 100644
--- a/backend/app/api/v1/endpoints/products.py
+++ b/backend/app/api/v1/endpoints/products.py
@@ -26,13 +26,22 @@ router = APIRouter(prefix="/products", tags=["产品管理"])
# ============================================================
@router.get("/qrcode/{serial_number}")
-async def get_product_qrcode(
- serial_number: str,
- current_user: dict = Depends(get_current_user),
-):
+async def get_product_qrcode(serial_number: str):
"""
生成产品二维码(PNG 图片)。
- 内容为 16 位序列号,扫描后可调用 /scan/{serial_number} 查询产品。
+
+ ⚠️ 本接口【刻意不加鉴权】:
+ 前端以 `
` 引用它,而
+ 无法携带 Authorization 头 —— 加了鉴权会让所有二维码图片加载失败
+ (页面上显示成破图),并在审计里刷出大量 401。
+
+ 不加鉴权是安全的:本函数**不查数据库**,只校验长度并把这个字符串渲染成
+ 二维码,没有任何业务数据泄露面(序列号本身就是调用方提供的)。
+
+ 也刻意不支持 ?token= 兜底:把 JWT 放进 URL 会渗进访问日志、浏览器历史
+ 与 Referer,比它想解决的问题更糟。
+
+ 内容为序列号,扫描后可调用 /scan/{serial_number} 查询产品。
尺寸:300×300 px,用于 PC 端打印或嵌入标签。
"""
if len(serial_number) != 16:
diff --git a/backend/app/core/config.py b/backend/app/core/config.py
index b1e7c27..5126d4a 100644
--- a/backend/app/core/config.py
+++ b/backend/app/core/config.py
@@ -38,6 +38,9 @@ class Settings(BaseSettings):
# 全仓库的部门过滤点只有三处 SQL:登录、人员列表、物料选择器,全部引用这里。
ORG_DEPARTMENT: str = "LICA" # MOM sys_user.department 的取值
MATERIAL_CATEGORY_PREFIX: str = "LICA/" # MOM material_base.category 的部门前缀
+ # 产品序列号前缀(产品身份证 / 二维码内容)。留空则生成纯 16 位 HEX。
+ # LICA 用 "L" 打头,一眼区分部门来源;IRIS 实例保持空串不受影响。
+ SERIAL_PREFIX: str = "L"
@property
def CORS_ORIGINS_LIST(self) -> list[str]:
diff --git a/backend/app/services/counter_service.py b/backend/app/services/counter_service.py
index ac4535e..4e9f5e5 100644
--- a/backend/app/services/counter_service.py
+++ b/backend/app/services/counter_service.py
@@ -1,9 +1,14 @@
-"""16进制自增计数器 — 基于 PostgreSQL Sequence,生成 16 位 HEX 唯一 ID"""
+"""序列号计数器 — 基于 PostgreSQL Sequence,生成定长序列号(产品身份证)"""
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
+from app.core.config import settings
+
SEQUENCE_NAME = "product_hex_counter"
+# 序列号总长度(与 products.serial_number String(16)、前端的 16 位校验一致)
+SERIAL_LENGTH = 16
+
async def ensure_sequence(db: AsyncSession) -> None:
"""确保 counter sequence 存在(幂等)"""
@@ -12,15 +17,30 @@ async def ensure_sequence(db: AsyncSession) -> None:
)
-async def next_hex_id(db: AsyncSession, length: int = 16) -> str:
+async def next_hex_id(db: AsyncSession, length: int = SERIAL_LENGTH) -> str:
"""
- 生成下一个 hex ID。
+ 生成下一个序列号:{部门前缀}{递增计数器的 16 进制},总长固定为 length。
- 示例: 1 → "0000000000000001"
- 15 → "000000000000000F"
- 16 → "0000000000000010"
- 255 → "00000000000000FF"
+ 前缀来自 settings.SERIAL_PREFIX(LICA 是 "L",IRIS 留空),
+ 用来在一眼扫号时区分部门来源;计数器部分保持 HEX,便于人工核对数量。
+
+ 示例(SERIAL_PREFIX = "L"):
+ 1 → "L000000000000001"
+ 255 → "L0000000000000FF"
+ 示例(SERIAL_PREFIX = "",IRIS 实例):
+ 1 → "0000000000000001"
+ 255 → "00000000000000FF"
+
+ ⚠️ 长度必须严格等于 length:products.serial_number 是 String(16),
+ 且 /products/qrcode/{sn} 接口会校验 16 位,前缀是从 HEX 位里让出来的。
"""
result = await db.execute(text(f"SELECT nextval('{SEQUENCE_NAME}');"))
counter: int = result.scalar_one()
- return format(counter, f"0{length}X")
+
+ prefix = settings.SERIAL_PREFIX or ""
+ hex_length = length - len(prefix)
+ if hex_length <= 0:
+ raise ValueError(
+ f"SERIAL_PREFIX({prefix!r}) 过长,没有给计数器留下位数"
+ )
+ return f"{prefix}{format(counter, f'0{hex_length}X')}"