feat: complete real customer import and web auth flow

This commit is contained in:
taiyi 2026-05-15 12:18:50 +08:00
parent ca04891991
commit 7e69e9b74b
16 changed files with 445 additions and 65 deletions

View File

@ -1,7 +1,9 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from backend.app.api.deps import get_supplier_service
from backend.app.api.deps import get_supplier_service, require_roles
from backend.app.core.error_codes import ErrorCode
from backend.app.core.exceptions import AppException
from backend.app.db import get_db_session
from backend.app.schemas.common import success_payload
from backend.app.schemas.suppliers import CreateSupplierRequest
@ -19,6 +21,7 @@ def list_suppliers(
page_size: int = Query(default=20),
supplier_service: SupplierService = Depends(get_supplier_service),
session: Session = Depends(get_db_session),
current_user: dict = Depends(require_roles("salesman", "manager", "admin")),
) -> dict:
result = supplier_service.list_suppliers(
{"supplier_name": supplier_name, "supplier_type": supplier_type, "status": status},
@ -36,5 +39,19 @@ def create_supplier(
payload: CreateSupplierRequest,
supplier_service: SupplierService = Depends(get_supplier_service),
session: Session = Depends(get_db_session),
current_user: dict = Depends(require_roles("manager", "admin")),
) -> dict:
return success_payload(supplier_service.create_supplier(payload.model_dump(), session))
@router.get("/{supplier_id}")
def get_supplier(
supplier_id: int,
supplier_service: SupplierService = Depends(get_supplier_service),
session: Session = Depends(get_db_session),
current_user: dict = Depends(require_roles("salesman", "manager", "admin")),
) -> dict:
supplier = supplier_service.get_supplier(supplier_id, session)
if not supplier:
raise AppException(code=ErrorCode.NOT_FOUND, message="供应商不存在", status_code=404)
return success_payload(supplier)

View File

@ -42,6 +42,20 @@ class CustomerRepository:
session.flush()
return customer
def update_customer(self, session: Session, customer: Customer, payload: dict) -> Customer:
customer.customer_name = payload["customer_name"]
customer.mobile = payload["mobile"]
customer.address = payload.get("address")
customer.settlement_type = payload.get("settlement_type")
customer.settlement_days = payload.get("settlement_days", 0)
customer.customer_type = payload.get("customer_type")
customer.salesman_id = payload.get("salesman_id")
customer.credit_limit = payload.get("credit_limit", 0)
customer.remark = payload.get("remark")
session.add(customer)
session.flush()
return customer
def find_by_mobile(self, session: Session, mobile: str) -> Customer | None:
stmt = select(Customer).where(
Customer.mobile == mobile,

View File

@ -36,3 +36,7 @@ class SupplierRepository:
session.add(supplier)
session.flush()
return supplier
def get_supplier(self, session: Session, supplier_id: int) -> Supplier | None:
stmt = select(Supplier).where(Supplier.id == supplier_id, Supplier.deleted == 0)
return session.execute(stmt).scalar_one_or_none()

View File

@ -1,3 +1,6 @@
import csv
from pathlib import Path
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
@ -179,38 +182,10 @@ class CustomerService:
if session is not None:
try:
existed = self.repository.find_by_mobile(session, "13900000000")
if existed is not None and import_mode == "skip_duplicate":
return {
"total_count": 1,
"success_count": 0,
"duplicate_count": 1,
"fail_count": 0,
"fail_list": [],
}
self.repository.create_customer(
session,
{
"customer_name": "导入客户",
"mobile": "13900000000",
"address": "导入地址",
"settlement_type": "monthly",
"settlement_days": 30,
"customer_type": "channel",
"salesman_id": 1,
"credit_limit": 0,
"remark": f"导入文件:{file_url}",
},
)
rows = self._load_import_rows(file_url)
result = self._import_customer_rows(session, rows, import_mode, file_url)
session.commit()
return {
"total_count": 1,
"success_count": 1,
"duplicate_count": 0,
"fail_count": 0,
"fail_list": [],
}
return result
except AppException:
session.rollback()
raise
@ -225,6 +200,124 @@ class CustomerService:
"fail_list": [{"row_no": 12, "reason": "手机号为空"}],
}
def _load_import_rows(self, file_url: str) -> list[dict]:
path = self._resolve_import_path(file_url)
if not path.exists() or not path.is_file():
raise AppException(code=ErrorCode.PARAM_ERROR, message="导入文件不存在", status_code=400)
suffix = path.suffix.lower()
if suffix == ".csv":
with path.open("r", encoding="utf-8-sig", newline="") as file:
return list(csv.DictReader(file))
if suffix in {".xlsx", ".xls"}:
try:
from openpyxl import load_workbook
except ImportError as exc:
raise AppException(code=ErrorCode.SYSTEM_ERROR, message="缺少 Excel 解析依赖 openpyxl", status_code=500) from exc
workbook = load_workbook(path, read_only=True, data_only=True)
sheet = workbook.active
rows = list(sheet.iter_rows(values_only=True))
if not rows:
return []
headers = [str(item).strip() if item is not None else "" for item in rows[0]]
return [
{headers[index]: value for index, value in enumerate(row) if index < len(headers)}
for row in rows[1:]
]
raise AppException(code=ErrorCode.PARAM_ERROR, message="仅支持导入 csv、xlsx、xls 文件", status_code=400)
def _resolve_import_path(self, file_url: str) -> Path:
normalized = file_url.replace("\\", "/")
if normalized.startswith(("http://", "https://")):
raise AppException(code=ErrorCode.PARAM_ERROR, message="当前阶段仅支持本地文件路径导入", status_code=400)
path = Path(file_url)
if not path.is_absolute():
path = Path.cwd() / path
return path
def _import_customer_rows(self, session: Session, rows: list[dict], import_mode: str, file_url: str) -> dict:
fail_list: list[dict] = []
success_count = 0
duplicate_count = 0
for index, raw_row in enumerate(rows, start=2):
try:
payload = self._normalize_import_row(raw_row, file_url)
existed = self.repository.find_by_mobile(session, payload["mobile"])
if existed is not None:
duplicate_count += 1
if import_mode == "skip_duplicate":
continue
self.repository.update_customer(session, existed, payload)
success_count += 1
continue
self.repository.create_customer(session, payload)
success_count += 1
except AppException as exc:
fail_list.append({"row_no": index, "reason": exc.message})
return {
"total_count": len(rows),
"success_count": success_count,
"duplicate_count": duplicate_count,
"fail_count": len(fail_list),
"fail_list": fail_list,
}
def _normalize_import_row(self, raw_row: dict, file_url: str) -> dict:
customer_name = self._pick_import_value(raw_row, ["customer_name", "客户姓名", "姓名"])
mobile = self._pick_import_value(raw_row, ["mobile", "手机号", "手机号码"])
if not customer_name:
raise AppException(code=ErrorCode.PARAM_ERROR, message="客户姓名不能为空", status_code=400)
if not mobile:
raise AppException(code=ErrorCode.PARAM_ERROR, message="手机号不能为空", status_code=400)
settlement_days = self._parse_int(self._pick_import_value(raw_row, ["settlement_days", "账期天数"]), 0)
salesman_id = self._parse_int(self._pick_import_value(raw_row, ["salesman_id", "业务员ID"]), None)
credit_limit = self._parse_float(self._pick_import_value(raw_row, ["credit_limit", "信用额度"]), 0)
return {
"customer_name": customer_name,
"mobile": mobile,
"address": self._pick_import_value(raw_row, ["address", "客户地址", "地址"]) or None,
"settlement_type": self._pick_import_value(raw_row, ["settlement_type", "结算方式"]) or "monthly",
"settlement_days": settlement_days,
"customer_type": self._pick_import_value(raw_row, ["customer_type", "客户类型"]) or "channel",
"salesman_id": salesman_id,
"credit_limit": credit_limit,
"remark": self._pick_import_value(raw_row, ["remark", "备注"]) or f"导入文件:{file_url}",
}
def _pick_import_value(self, raw_row: dict, keys: list[str]) -> str:
for key in keys:
value = raw_row.get(key)
if value is None:
continue
text = str(value).strip()
if text:
return text
return ""
def _parse_int(self, value: str, default: int | None) -> int | None:
if value == "":
return default
try:
return int(float(value))
except ValueError as exc:
raise AppException(code=ErrorCode.PARAM_ERROR, message="数字字段格式不正确", status_code=400) from exc
def _parse_float(self, value: str, default: float) -> float:
if value == "":
return default
try:
return float(value)
except ValueError as exc:
raise AppException(code=ErrorCode.PARAM_ERROR, message="金额字段格式不正确", status_code=400) from exc
def normalize_list_filters(self, filters: dict | None, current_user: dict | None = None) -> dict:
normalized = dict(filters or {})
if current_user and current_user.get("role_code") == "salesman":

View File

@ -103,5 +103,39 @@ class SupplierService:
"status": payload.get("status", 1),
}
def get_supplier(self, supplier_id: int, session: Session | None = None) -> dict:
if session is not None:
try:
supplier = self.repository.get_supplier(session, supplier_id)
if supplier is not None:
return {
"supplier_id": supplier.id,
"supplier_name": supplier.supplier_name,
"supplier_type": supplier.supplier_type,
"contact_name": supplier.contact_name,
"contact_mobile": supplier.contact_mobile,
"address": supplier.address,
"template_type": supplier.template_type,
"status": supplier.status,
"remark": supplier.remark,
}
raise AppException(code=ErrorCode.NOT_FOUND, message="供应商不存在", status_code=404)
except AppException:
raise
except SQLAlchemyError:
pass
return {
"supplier_id": supplier_id,
"supplier_name": "工厂A",
"supplier_type": "factory",
"contact_name": "王师傅",
"contact_mobile": "13800000001",
"address": "演示工厂地址",
"template_type": "default",
"status": 1,
"remark": "演示备注",
}
supplier_service = SupplierService()

View File

@ -5,3 +5,4 @@ pydantic-settings==2.9.1
sqlalchemy==2.0.40
alembic==1.15.2
pymysql==1.1.1
openpyxl==3.1.5

View File

@ -1,6 +1,6 @@
<template>
<div class="layout">
<aside class="sidebar">
<aside v-if="showSidebar" class="sidebar">
<h1>管理后台</h1>
<nav>
<RouterLink to="/dashboard">工作台</RouterLink>
@ -8,6 +8,7 @@
<RouterLink to="/system">系统管理</RouterLink>
<RouterLink to="/master-data">基础资料</RouterLink>
<RouterLink to="/business">业务模块</RouterLink>
<button class="logout-btn" @click="handleLogout">退出</button>
</nav>
</aside>
<main class="content">
@ -16,6 +17,22 @@
</div>
</template>
<script setup>
import { computed } from "vue";
import { useRoute, useRouter } from "vue-router";
import { clearAdminToken } from "./mockApi";
const route = useRoute();
const router = useRouter();
const showSidebar = computed(() => route.path !== "/login");
function handleLogout() {
clearAdminToken();
router.push("/login");
}
</script>
<style scoped>
.layout {
font-family: Arial, sans-serif;
@ -52,6 +69,15 @@
font-weight: 600;
}
.logout-btn {
border: 1px solid #374151;
background: #111827;
color: #f9fafb;
border-radius: 8px;
padding: 10px 12px;
text-align: left;
}
.content {
padding: 24px;
}

View File

@ -10,6 +10,7 @@ import {
} from "./data";
const API_BASE_URL = "http://127.0.0.1:8000";
const ADMIN_TOKEN_KEY = "admin_token";
function wait(data) {
return new Promise((resolve) => {
@ -29,9 +30,11 @@ function buildQuery(params = {}) {
}
async function request(path, options = {}) {
const token = localStorage.getItem(ADMIN_TOKEN_KEY) || "";
const response = await fetch(`${API_BASE_URL}${path}`, {
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(options.headers || {}),
},
...options,
@ -43,6 +46,32 @@ async function request(path, options = {}) {
return result.data;
}
export async function loginAdminUser(form) {
const data = await request("/api/auth/login", {
method: "POST",
body: JSON.stringify({
username: form.username,
password: form.password,
role_type: "admin",
}),
});
localStorage.setItem(ADMIN_TOKEN_KEY, data.token || "");
return {
user: {
userId: data.user_id,
username: data.username,
realName: data.real_name,
roleName: data.role_name,
roleCode: data.role_code,
},
token: data.token,
};
}
export function clearAdminToken() {
localStorage.removeItem(ADMIN_TOKEN_KEY);
}
function mapOrderStatus(status) {
const statusMap = {
draft: "草稿",
@ -552,17 +581,21 @@ export async function fetchSupplierList(filters = {}) {
}
export async function fetchSupplierDetail(supplierId) {
return wait({
supplier_id: supplierId,
supplier_name: "工厂A",
supplier_type: "factory",
contact_name: "王师傅",
contact_mobile: "13800000001",
address: "演示工厂地址",
template_type: "default",
status: 1,
remark: "演示备注",
});
try {
return await request(`/api/suppliers/${supplierId}`);
} catch (error) {
return wait({
supplier_id: supplierId,
supplier_name: "工厂A",
supplier_type: "factory",
contact_name: "王师傅",
contact_mobile: "13800000001",
address: "演示工厂地址",
template_type: "default",
status: 1,
remark: "演示备注",
});
}
}
export async function createSupplier(payload) {

View File

@ -1,5 +1,6 @@
import { createRouter, createWebHistory } from "vue-router";
import LoginPage from "./views/LoginPage.vue";
import DashboardPage from "./views/DashboardPage.vue";
import SystemPage from "./views/SystemPage.vue";
import MasterDataPage from "./views/MasterDataPage.vue";
@ -8,7 +9,8 @@ import ApprovalsPage from "./views/ApprovalsPage.vue";
import ApprovalDetailPage from "./views/ApprovalDetailPage.vue";
const routes = [
{ path: "/", redirect: "/dashboard" },
{ path: "/", redirect: "/login" },
{ path: "/login", component: LoginPage },
{ path: "/dashboard", component: DashboardPage },
{ path: "/approvals", component: ApprovalsPage },
{ path: "/approvals/:id", component: ApprovalDetailPage },
@ -21,3 +23,14 @@ export const router = createRouter({
history: createWebHistory(),
routes
});
router.beforeEach((to) => {
const hasToken = Boolean(localStorage.getItem("admin_token"));
if (to.path !== "/login" && !hasToken) {
return "/login";
}
if (to.path === "/login" && hasToken) {
return "/dashboard";
}
return true;
});

View File

@ -0,0 +1,59 @@
<template>
<section class="page">
<h2>后台登录</h2>
<div class="form">
<label>
<span>账号</span>
<input v-model="form.username" type="text" />
</label>
<label>
<span>密码</span>
<input v-model="form.password" type="password" />
</label>
<button :disabled="loading" @click="handleLogin">
{{ loading ? "登录中..." : "登录" }}
</button>
<p v-if="message" class="message">{{ message }}</p>
</div>
</section>
</template>
<script setup>
import { reactive, ref } from "vue";
import { useRouter } from "vue-router";
import { loginAdminUser } from "../mockApi";
const router = useRouter();
const loading = ref(false);
const message = ref("");
const form = reactive({
username: "admin01",
password: "123456",
});
async function handleLogin() {
loading.value = true;
message.value = "";
try {
const result = await loginAdminUser(form);
message.value = `欢迎,${result.user.realName}`;
router.push("/dashboard");
} catch (error) {
message.value = error.message || "登录失败";
} finally {
loading.value = false;
}
}
</script>
<style scoped>
.page { background: #fff; border-radius: 8px; padding: 20px; }
h2 { margin: 0 0 12px; }
.form { display: grid; gap: 14px; max-width: 320px; }
label { display: grid; gap: 6px; }
span { color: #4b5563; font-size: 14px; }
input { border: 1px solid #d1d5db; border-radius: 8px; padding: 10px 12px; }
button { border: 1px solid #d1d5db; background: #111827; color: #fff; border-radius: 8px; padding: 10px 14px; }
.message { margin: 0; color: #047857; }
</style>

View File

@ -144,8 +144,8 @@
</div>
<div class="form-grid">
<label class="full-width">
<span>文件地址</span>
<input v-model.trim="customerImportForm.file_url" type="text" placeholder="请输入导入文件地址" />
<span>文件路径</span>
<input v-model.trim="customerImportForm.file_url" type="text" placeholder="请输入本地 csv/xlsx 文件路径" />
</label>
<label>
<span>导入模式</span>

View File

@ -6,6 +6,7 @@ import {
reminderRows,
reminderTypes,
} from "./data";
import { salesStore } from "./store";
const API_BASE_URL = "http://127.0.0.1:8000";
@ -27,9 +28,11 @@ function buildQuery(params = {}) {
}
async function request(path, options = {}) {
const token = salesStore.token;
const response = await fetch(`${API_BASE_URL}${path}`, {
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(options.headers || {}),
},
...options,
@ -61,14 +64,38 @@ function maskMobile(mobile) {
return `${mobile.slice(0, 3)}****${mobile.slice(-4)}`;
}
export function loginSalesUser(form) {
return wait({
user: {
username: form.username || "sales01",
realName: "演示业务员",
role: "业务员",
},
});
export async function loginSalesUser(form) {
try {
const data = await request("/api/auth/login", {
method: "POST",
body: JSON.stringify({
username: form.username,
password: form.password,
role_type: "salesman",
}),
});
return {
user: {
userId: data.user_id,
username: data.username,
realName: data.real_name,
role: data.role_name,
roleCode: data.role_code,
},
token: data.token,
isMock: false,
};
} catch (error) {
return wait({
user: {
username: form.username || "sales01",
realName: "演示业务员",
role: "业务员",
},
token: "",
isMock: true,
});
}
}
function normalizeOrderFilters(filters = {}) {

View File

@ -19,3 +19,14 @@ export const router = createRouter({
history: createWebHistory(),
routes
});
router.beforeEach((to) => {
const hasToken = Boolean(localStorage.getItem("sales_token"));
if (to.path !== "/login" && !hasToken) {
return "/login";
}
if (to.path === "/login" && hasToken) {
return "/orders";
}
return true;
});

View File

@ -1,13 +1,51 @@
import { reactive } from "vue";
const SALES_USER_KEY = "sales_user";
const SALES_TOKEN_KEY = "sales_token";
export const salesStore = reactive({
user: null
user: loadSalesUser(),
token: loadSalesToken(),
});
export function setSalesUser(user) {
salesStore.user = user;
if (user) {
localStorage.setItem(SALES_USER_KEY, JSON.stringify(user));
} else {
localStorage.removeItem(SALES_USER_KEY);
}
}
export function setSalesToken(token) {
salesStore.token = token || "";
if (token) {
localStorage.setItem(SALES_TOKEN_KEY, token);
} else {
localStorage.removeItem(SALES_TOKEN_KEY);
}
}
export function clearSalesUser() {
salesStore.user = null;
salesStore.token = "";
localStorage.removeItem(SALES_USER_KEY);
localStorage.removeItem(SALES_TOKEN_KEY);
}
function loadSalesUser() {
const raw = localStorage.getItem(SALES_USER_KEY);
if (!raw) {
return null;
}
try {
return JSON.parse(raw);
} catch {
localStorage.removeItem(SALES_USER_KEY);
return null;
}
}
function loadSalesToken() {
return localStorage.getItem(SALES_TOKEN_KEY) || "";
}

View File

@ -23,7 +23,7 @@ import { onMounted, reactive, ref } from "vue";
import { useRouter } from "vue-router";
import { loginSalesUser } from "../mockApi";
import { salesStore, setSalesUser } from "../store";
import { salesStore, setSalesToken, setSalesUser } from "../store";
const router = useRouter();
const loading = ref(false);
@ -36,11 +36,17 @@ const form = reactive({
async function handleLogin() {
loading.value = true;
message.value = "";
const result = await loginSalesUser(form);
setSalesUser(result.user);
message.value = `欢迎,${result.user.realName}`;
loading.value = false;
router.push("/orders");
try {
const result = await loginSalesUser(form);
setSalesUser(result.user);
setSalesToken(result.token);
message.value = result.isMock ? `欢迎,${result.user.realName}(当前为演示登录)` : `欢迎,${result.user.realName}`;
router.push("/orders");
} catch (error) {
message.value = error.message || "登录失败";
} finally {
loading.value = false;
}
}
onMounted(() => {

View File

@ -14,6 +14,7 @@
| 后端 FastAPI 基础工程初始化 | 05-开发实施与部署准备清单 | `backend` 下存在可启动服务与 `/health` | 已完成 |
| 前端目录骨架初始化 | 05-开发实施与部署准备清单 | `frontend` 下存在各端目录 | 已完成 |
| 环境变量模板 | 05-开发实施与部署准备清单 | `backend/.env.example` 存在 | 已完成 |
| 后端依赖声明补齐 | 05-开发实施与部署准备清单 | `backend/requirements.txt` 已补当前真实功能依赖(含 `openpyxl` | 已完成 |
## 二、页面与前端
@ -31,9 +32,9 @@
| 认证接口 | 02-API细化设计 | 已接入数据库优先登录校验、密码哈希校验、签名 token 生成、`/me` 过期校验、`/logout` 失效处理,并补齐关键业务接口统一 token 鉴权入口与基础角色拦截;仍待继续补 token 持久化黑名单与更细粒度权限点 | 待完成 |
| 订单接口 | 02-API细化设计 | 已支持真实建单、列表筛选、详情、提交、取消、审批、发厂确认与状态变更,并在 `shipped/delivered` 等节点接入欠款生成;已补业务员本人订单数据范围控制与详情敏感字段基础收口,更多异常场景仍待补 | 待完成 |
| 审批接口 | 02-API细化设计 | 已支持真实订单审批、取消审批和审批日志写入,并补齐管理层/管理员角色限制;更多审批流分支与权限颗粒度仍待补 | 待完成 |
| 客户接口 | 02-API细化设计 | 已有接口与 service客户列表、创建、详情已接入真实数据库优先链路并补齐业务员本人客户数据范围控制导入仅完成最小落库样例,尚未按文件内容做真实解析导入 | 待完成 |
| 客户接口 | 02-API细化设计 | 已有接口与 service客户列表、创建、详情已接入真实数据库优先链路并补齐业务员本人客户数据范围控制客户导入已支持本地 `csv/xlsx/xls` 真实解析、逐行校验、跳过重复/覆盖重复导入,后续仅待进一步补 OSS 文件来源接入 | 待完成 |
| 产品与分类接口 | 02-API细化设计 | 已有接口与 service产品分类列表/新增/更新、产品列表/详情已接入真实数据库优先链路,基础资料页已形成真实联调闭环 | 待完成 |
| 供应商接口 | 02-API细化设计 | 已有接口与 service供应商列表、创建已接入真实数据库优先链路;前端详情展示仍为演示态补充,不计入真实接口完成度 | 待完成 |
| 供应商接口 | 02-API细化设计 | 已有接口与 service供应商列表、创建、详情已接入真实数据库优先链路,基础资料页供应商详情已形成真实联调;更多权限与资料维护边界仍待补 | 待完成 |
| 司机任务接口 | 02-API细化设计 | 已支持真实任务创建、列表、详情、接单/揽货/送达状态联动,并补齐司机本人任务/轨迹访问限制与管理端创建权限限制;附件留痕细节与更多异常场景仍待补 | 待完成 |
| 文件接口 | 02-API细化设计 | 上传凭证生成和附件记录保存已接入数据库优先链路,并补齐 OSS 元数据口径;仍未接真实 OSS 签名与上传校验 | 待完成 |
| 物流与 AI 接口 | 02-API细化设计 | 物流轨迹查询与新增已接入数据库优先链路,司机接单/揽货/送达会写入真实轨迹AI 识别记录保存、置信度回包、人工修正落库已接入数据库优先链路,真实阿里云 OCR 与第三方轨迹仍未接入 | 待完成 |
@ -71,11 +72,13 @@
- `mini-manager` 管理层端审批与履约下发主线
- `mini-driver` 司机端任务执行主线
- 订单、审批、发厂确认、司机任务创建等主链路的数据库优先实现
- `web-admin` 管理后台真实登录入口与 token 鉴权联调
- `web-sales` 业务员端真实登录入口与 token 鉴权联调
### 仍未真正完成
- 更完整的权限拦截与数据范围控制(当前已完成统一鉴权入口、关键角色拦截和订单/客户/司机任务的基础数据范围控制)
- 客户导入真实文件解析、供应商详情前后端完全真实化
- 客户导入 OSS 文件来源接入与更多导入模板兼容
- 真实 OSS / 阿里云 OCR / 报表导出 OSS 发布链路
- Alembic 迁移、初始化脚本、系统测试与部署收口
@ -128,3 +131,4 @@
| 2026-05-15 | 已继续成组推进系统管理审计主线:用户新增/编辑/重置密码、角色新增/编辑、角色授权、菜单新增/编辑等系统管理关键写操作已开始写入 `audit_log`,系统管理模块已从“有写接口”推进到“有写接口且可追溯”。 |
| 2026-05-15 | 已继续成组推进认证主线:数据库优先登录已切到密码哈希校验与签名 token`/api/auth/me` 已支持 token 解析和过期判断,`/api/auth/logout` 已接入基础失效处理;当前仍未统一补到所有业务接口的角色/数据范围拦截。 |
| 2026-05-15 | 已继续成组推进“统一鉴权 + 数据范围控制”主线:新增通用 token 鉴权依赖,订单/客户/司机任务/系统管理关键接口已补齐角色限制;业务员现仅可访问本人订单与客户,司机现仅可访问本人任务与轨迹,订单详情已按接口文档对业务员隐藏成本/利润等敏感字段。 |
| 2026-05-15 | 已继续完成基础资料与登录联调大功能:客户导入已从最小样例升级为本地 `csv/xlsx/xls` 真实解析导入,支持逐行校验、跳过重复和覆盖重复;供应商详情接口已接入真实数据库优先链路;`web-admin` 与 `web-sales` 已补真实登录入口、token 持久化与请求透传,以适配后端已启用的统一鉴权。 |