Files
cma-management/backend/app/api/products.py
T
fullstack-bot b815b7f623 fix(security): close unauthenticated CMA user API surface + audit tool (cma-api-auth-surface-audit-20260914)
Root cause: main.py include_router had no global auth dependency; user routers
(products/cost/predict/...) relied on assert_entity_scope, which treats a
missing session token as the Bot service channel and lets the request through.
Result: 46 user endpoints returned data without any token on a public site.

Changes:
- auth_middleware.py: add require_auth_401 (missing Bearer -> 401, delegates to
  require_auth otherwise); 401/403 semantics are explicit.
- router-level Depends(require_auth_401) on products, cost, predict,
  notifications, permissions, tenant, alignment; endpoint-level on
  /auth/roles and /admin/cache/clear.
- deps.py: document that get_entity_id/assert_entity_scope are NOT auth.
- frontend: add the missing Bearer header to 5 tokenless fetch/axios calls that
  hit the newly protected endpoints (KPIAlignment x2, RolePermissions x2,
  Dashboard x1).
- tests: new test_api_auth_surface_20260914.py (58 cases: no-token 401,
  token 200, cross-entity 403, bot channel unchanged); adapt test_predict /
  test_kpi_forecast to send session tokens like production.
- scripts/audit_api_auth_surface.py: reusable FastAPI dependency-graph audit
  (423 endpoints; A bot / B gap / C public), exits 1 when a B-class gap exists.
2026-09-14 16:41:15 +08:00

173 lines
6.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""波士顿产品矩阵 API — 四象限分析"""
from fastapi import APIRouter, Depends, Query, Request
from typing import Optional
from sqlalchemy.orm import Session
from app.database import get_db
from app.deps import assert_entity_scope
from app.auth_middleware import require_auth_401
from app.models import ProductSales
from collections import defaultdict
# 用户端点:统一要求会话 Bearer 鉴权(cma-api-auth-surface-audit-20260914)
# 无 token → 401(原「无 token 亦可取数」缺口收口);Bot 通道走 /api/cma/bot/*(X-BOT-KEY)不受影响。
router = APIRouter(prefix="/api/cma/products", tags=["产品矩阵"],
dependencies=[Depends(require_auth_401)])
def _calc_quadrant(trend: float, margin: float) -> str:
"""四象限分类:
横轴=近3月销售趋势(正=增长),纵轴=毛利率
明星(Star) = 高趋势+高毛利
现金牛(CashCow) = 低趋势+高毛利
问题(QuestionMark) = 高趋势+低毛利
瘦狗(Dog) = 低趋势+低毛利
"""
trend_high = trend >= 0
margin_high = margin >= 0
if trend_high and margin_high:
return "star"
if not trend_high and margin_high:
return "cash_cow"
if trend_high and not margin_high:
return "question_mark"
return "dog"
@router.get("/matrix")
def get_product_matrix(
request: Request,
entity_id: Optional[int] = Query(None, ge=1, description="实体ID(须与会话账套一致);缺省按会话账套"),
months: int = Query(3, ge=1, le=6, description="趋势计算月数"),
db: Session = Depends(get_db),
):
"""产品矩阵:横轴=销售趋势,纵轴=毛利率,气泡=销售额
多租户(cma-cross-entity-endpoints-auth-20260914):
登录会话以 token 绑定账套为准,显式 entity_id 不一致 → 403(同 growth-quality 文案)。
"""
session_entity = assert_entity_scope(request, entity_id, db)
target_entity = session_entity if session_entity is not None else (entity_id or 1)
entity_id = target_entity
# 取最近 months+1 个月(多取1个月用于计算趋势)
periods = db.query(ProductSales.period_month).filter(
ProductSales.entity_id == entity_id
).distinct().order_by(ProductSales.period_month.desc()).limit(months + 1).all()
periods = sorted([p[0] for p in periods])
if len(periods) < 2:
return {
"entity_id": entity_id,
"has_data": False,
"message": "数据不足,至少需要2个月数据",
"quadrants": [],
"products": [],
}
trend_periods = periods[-months:] # 最近 months 个月
prev_periods = periods[:-months] if len(periods) > months else periods[:1]
# 加载数据
rows = db.query(ProductSales).filter(
ProductSales.entity_id == entity_id,
ProductSales.period_month.in_(periods),
).all()
# 按商品聚合
products = defaultdict(lambda: {
"code": "", "name": "", "months": {},
"total_sales": 0, "total_qty": 0, "total_gross": 0,
})
for row in rows:
p = products[row.product_code]
p["code"] = row.product_code
p["name"] = row.product_name
p["months"][row.period_month] = {
"sales": float(row.sales_amount or 0),
"margin": float(row.gross_margin_rate or 0),
"gross": float(row.gross_profit or 0),
"qty": int(row.sales_qty or 0),
}
p["total_sales"] += float(row.sales_amount or 0)
p["total_qty"] += int(row.sales_qty or 0)
p["total_gross"] += float(row.gross_profit or 0)
# 计算每个商品的趋势和毛利率
result_products = []
for code, p in products.items():
# 趋势 = 最近月份 vs 前一月的环比(取趋势期间的平均环比增速)
# 用最近3个月的销售序列做简单线性趋势
trend_sales = []
for pp in periods:
trend_sales.append(p["months"].get(pp, {}).get("sales", 0))
# 线性回归斜率(最小二乘)
n = len(trend_sales)
if n >= 2:
xs = list(range(n))
x_mean = sum(xs) / n
y_mean = sum(trend_sales) / n
numerator = sum((xs[i] - x_mean) * (trend_sales[i] - y_mean) for i in range(n))
denominator = sum((xs[i] - x_mean) ** 2 for i in range(n))
slope = numerator / denominator if denominator else 0
# 斜率转为百分比(相对期间平均销售)
avg = y_mean if y_mean != 0 else 1
trend = slope / abs(avg) * 100
else:
trend = 0.0
# 毛利率 = 加权平均(按销售额)
weighted_margin = 0.0
total_sales_for_margin = 0
for pp in trend_periods:
m = p["months"].get(pp)
if m and m["sales"] > 0:
weighted_margin += m["margin"] * m["sales"]
total_sales_for_margin += m["sales"]
if total_sales_for_margin > 0:
weighted_margin = weighted_margin / total_sales_for_margin
else:
# 无销售用平均毛利率
margins = [p["months"][pp]["margin"] for pp in p["months"] if p["months"][pp]["margin"] != 0]
weighted_margin = sum(margins) / len(margins) if margins else 0
quadrant = _calc_quadrant(trend, weighted_margin)
result_products.append({
"code": code,
"name": p["name"],
"total_sales": round(p["total_sales"], 2),
"total_qty": p["total_qty"],
"total_gross": round(p["total_gross"], 2),
"trend_pct": round(trend, 1),
"margin_pct": round(weighted_margin, 1),
"quadrant": quadrant,
})
# 按销售额排序
result_products.sort(key=lambda x: -x["total_sales"])
# 四象限汇总
quadrant_labels = {
"star": {"label": "明星产品", "icon": "🌟", "advice": "高增长+有毛利,重点主推,加大投入"},
"cash_cow": {"label": "现金牛", "icon": "🥇", "advice": "销量大但增长放缓,维持稳定产出"},
"question_mark": {"label": "问题产品", "icon": "❓", "advice": "增长好但毛利低,优化成本或提价"},
"dog": {"label": "瘦狗产品", "icon": "🐶", "advice": "低增长+低毛利,考虑清库存或停产"},
}
quadrants = []
for q in ["star", "cash_cow", "question_mark", "dog"]:
items = [p for p in result_products if p["quadrant"] == q]
quadrants.append({
"key": q,
**quadrant_labels[q],
"count": len(items),
"products": items,
})
return {
"entity_id": entity_id,
"has_data": True,
"periods": periods,
"months_analyzed": months,
"quadrants": quadrants,
"products": result_products,
}