12451 f80b3b634c fix: 五角度审查后的安全/正确性/契约修复(含 order_timeout 反条件、连接接管、权限兜底)
## 严重问题
- order_timeout: 超时条件方向写反(`start_time < TIMESTAMPADD(HOUR,10,NOW())` 等价于
  `start_time < NOW()+10h`,条件恒真)→ 活跃订单会被当超时单提前结掉。改为
  `TIMESTAMPADD(HOUR, ?, start_time) < NOW()`,并补"刚建单不命中/超期命中"双向断言
- C1 device-server 连接池可被未验签的报文 dev_id 顶替(覆盖式 insert + 断连无条件 remove):
  改为 conn_id 归属模型 —— 唯一连接标识、remove_if/touch/is_authenticated 均带 conn 归属校验、
  接管时用 oneshot 信号主动驱逐旧连接、每条上行前用 is_owner 复核持有者身份
- C2 POST /api/devices/{imei}/command 零归属校验 + act 自由字符串 + 把 IMEI 塞进协议 id:
  补 scope_guard 归属校验 + act 白名单 + id 从 params 显式取(拒绝 15 位 IMEI)
- 非总管理员权限集恒为空(user_roles 从不写入且无分配入口)→ 全站 403:
  user_roles 为空时按 users.role 回退内置角色权限 + 建/改用户幂等同步映射(仅角色变化时同步)

## 订单生命周期并发正确性
- finalize_order 先取电量累加器后抢结束权 → off/bat_out 成对到达时 energy 被写 0:
  改为先原子抢占(WHERE compartment_id=? AND status=0 ORDER BY id DESC LIMIT 1),
  抢到者才 take_and_reset 取电量;抢不到直接返回且不碰累加器
- 建单"先查后插"无锁无唯一约束 → 同仓体两笔活跃单:
  条件插入(INSERT ... SELECT ... WHERE NOT EXISTS)+ 按仓体进程内锁
  + DB 层 active_flag 生成列 + UNIQUE 唯一索引(幂等迁移,有重复则记 ERROR 跳过,绝不阻断启动)
- handle_bat_out: updated==0 时不再复位占用;通道登记删除改为 battery_id 一致才删;
  新增 should_finalize_bat_out 挡住"迟到 bat_out 结束新单"
- 电压/电流加量程夹取(0–100000 mV/mA 之外按 0 计并 WARN),防位翻转导致积分暴增

## 协议/契约
- spec 绝不可能是 "0000"(生产 42/68 台拓扑为空):新增 build_spec 三级兜底
  (真实拓扑 → cabinets.cell×6 → 6×6),兜底生效记 WARN 报出拓扑数据
- /api/cabinets/realtime-status 改读真实字段(原来读的设备链路从不写的旧字段,恒返回 idle/空),
  用 derive_channel_view 推导旧字段以保持响应字段名不变;修掉"把通道号当板号"的幽灵板问题
- 小程序通道详情/PC 详情改读通道 Hash(弃用无写入方的 compartment:status:{id}),
  取不到返回 null 而非假的 0;补 voltage/current/temp/batt_status/pow_status
- 反馈列表补权限码 operation_log:view + status 筛选真正落到 SQL
- 小程序柜温换算(29800 曾被显示成 29800°C);签名注释 7→8 位;msg_id 序列化省略 null;
  parse_compartment_id 与 status_post 补板号 1–15/通道 1–8 量程校验(不再写 board:0 垃圾 key)

## 文档
- docs/柜控板固件对接文档.md:确立数值口径统一规则(hex 字节流=原始硬件单位·平台换算;
  JSON 数值字段=平台单位·设备换算)、修正 A.5.3 的 id 说明、作废变更记录原位标注(不删历史)、
  spec 表述改为"按拓扑现算+兜底、恒 4 位不会是 0000"、修复文首 7 项表格被引用块切断丢表头、
  修正 §10 误引 channels[].channel、写明无电池兜底阈值与 #16 风险

## 运维加固(服务器侧,非代码)
- 关闭 MySQL/Redis 公网暴露:nginx stream 由 0.0.0.0 改为只监听 127.0.0.1 + ZeroTier
- worker_shutdown_timeout 30s(原先每次 reload 泄漏永不退出的 worker)
- 测试证书改指向 app 证书(原测试证书 9 天后过期且无续期机制);补测试环境 uploads 目录;.env 0600

## 验收
- cargo check 两服务 0 error / 0 warning;cargo test api 88 + device 22 全过
- 路由 79 → 79 逐条一致;web tsc 干净
- 端到端联调(测试环境)13/13,订单结算与重构前逐字段一致;唯一索引已由迁移真实建出
- 生产已部署并验证:服务 active、health 200、ERROR 0、消费链路正常、迁移已建索引
2026-09-20 00:36:37 +08:00

268 lines
18 KiB
Vue
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.

<template>
<view class="home-page">
<view class="top-bar" :style="{ paddingTop: statusBarHeight + 'px', height: (statusBarHeight + navBarHeight) + 'px', paddingRight: '110px' }">
<view class="org-switcher" @click="showOrgPicker = true">
<text class="org-label">{{ currentOrgName || '选择组织' }}</text>
<view class="org-arrow"><AppIcon name="arrow_down" :size="12" color="#111827" /></view>
</view>
<view class="scan-btn" @click="handleScan">
<image src="/static/scan-icon.png" style="width: 24px; height: 24px;" />
</view>
</view>
<view v-if="selectedOrgId !== null" class="project-filter">
<view class="filter-inner">
<view class="filter-tag" :class="{ active: selectedProjectId === null }" @click="selectProject(null)"><text>全部</text></view>
<view v-for="p in projects" :key="p.id" class="filter-tag" :class="{ active: selectedProjectId === p.id }" @click="selectProject(p.id)"><text>{{ p.name }}</text></view>
</view>
</view>
<view class="stats-row">
<view class="stat-item"><text class="stat-value primary">{{ Number(stats.online_cabinets) || 0 }}</text><text class="stat-label">在线设备</text></view>
<view class="stat-item"><text class="stat-value muted">{{ Number(stats.total_channels) || 0 }}</text><text class="stat-label">总通道</text></view>
<view class="stat-item"><text class="stat-value success">{{ Number(stats.idle_channels) || 0 }}</text><text class="stat-label">空闲</text></view>
<view class="stat-item"><text class="stat-value primary">{{ Number(stats.charging_count) || 0 }}</text><text class="stat-label">充电中</text></view>
<view class="stat-item"><text class="stat-value warning">{{ Number(stats.full_count) || 0 }}</text><text class="stat-label">充满</text></view>
<view class="stat-item"><text class="stat-value danger">{{ Number(stats.fault_count) || 0 }}</text><text class="stat-label">故障</text></view>
</view>
<view class="section-header"><text class="section-title">设备列表</text><text class="section-count">{{ cabinets.length }} 台</text></view>
<view v-if="cabinets.length === 0 && !loading" class="empty-state">
<text class="empty-text">暂无设备</text>
</view>
<view v-else class="device-list">
<view v-for="cab in cabinets" :key="cab.id" class="device-card" @click="goDetail(cab.id)">
<view class="device-header">
<view class="device-id-row">
<text class="device-id">{{ cab.abstract_id }}</text>
<view class="status-badge" :class="cab.status === 1 ? 'online' : 'offline'"><text>{{ cab.status === 1 ? '在线' : '离线' }}</text></view>
</view>
<view class="header-right">
<text v-if="cab.temp != null" class="temp-badge">{{ formatCelsius(cab.temp) }}</text>
<view class="signal-inline">
<view class="signal" :class="signalClass(cab.rssi)">
<view class="signal-bar l1"></view><view class="signal-bar l2"></view><view class="signal-bar l3"></view><view class="signal-bar l4"></view><view class="signal-bar l5"></view>
</view>
<text class="rssi-value">{{ cab.rssi ?? '—' }}</text>
</view>
<view class="pow-tags">
<!-- 三态:停电(红)/ 正常(绿)/ 未知(灰)—— 无数据绝不能显示成"正常" -->
<view class="pow-tag" :class="powerFlagOf(cab.pow_fail_dc, 'DC').tone"><text>{{ powerFlagOf(cab.pow_fail_dc, 'DC').label }}</text></view>
<view class="pow-tag" :class="powerFlagOf(cab.pow_fail_ac, 'AC').tone"><text>{{ powerFlagOf(cab.pow_fail_ac, 'AC').label }}</text></view>
</view>
</view>
</view>
<text class="device-name">{{ cab.name || '未命名' }}</text>
<text v-if="cab.imei" class="device-imei">IMEI: {{ cab.imei }}</text>
<text v-if="cab.address" class="device-addr">{{ cab.address }}</text>
<view class="channel-stats">
<view class="channel-chip total"><AppIcon name="grid" :size="14" color="#6b7280" /><text class="chip-num">{{ Number(cab.channel_count) || 0 }}</text></view>
<view class="channel-chip idle"><AppIcon name="battery" :size="14" color="#16a34a" /><text class="chip-num">{{ Number(cab.idle_count) || 0 }}</text></view>
<view class="channel-chip charging"><AppIcon name="battery_charging" :size="14" color="#0f766e" /><text class="chip-num">{{ Number(cab.charging_count) || 0 }}</text></view>
<view class="channel-chip full"><AppIcon name="battery_full" :size="14" color="#d97706" /><text class="chip-num">{{ Number(cab.full_count) || 0 }}</text></view>
<view class="channel-chip fault"><AppIcon name="alert" :size="14" color="#dc2626" /><text class="chip-num">{{ Number(cab.fault_count) || 0 }}</text></view>
</view>
</view>
</view>
<view v-if="showOrgPicker" class="org-overlay" @click="showOrgPicker = false">
<view class="org-sheet" @click.stop>
<view class="org-header"><text class="org-title">切换组织</text><view class="org-close" @click="showOrgPicker = false"><text>✕</text></view></view>
<scroll-view class="org-list" scroll-y>
<!-- 组织接口失败(如操作员缺少 org:view → 403)时给出可见提示,避免弹层静默为空 -->
<view v-if="orgs.length === 0" class="org-empty"><text>暂无可选组织(账号可能缺少「查看组织」权限),当前按「全部」展示</text></view>
<view class="org-option" :class="{ selected: selectedOrgId === null }" @click="switchOrgAll"><text>全部</text></view>
<view v-for="org in orgs" :key="org.id" class="org-option" :class="{ selected: selectedOrgId === org.id }" @click="switchOrg(org)"><text>{{ org.name }}</text></view>
</scroll-view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
import { h5Api, type DashboardData, type CabinetItem, type OrgItem } from '../../api'
import { useAuthStore } from '../../store/auth'
import AppIcon from '../../components/AppIcon.vue'
import { formatCelsius, powerFlagOf } from '../../utils/deviceStatus'
const statusBarHeight = ref(47)
const navBarHeight = ref(44)
function calcNav() {
try {
const sys = wx.getSystemInfoSync()
statusBarHeight.value = sys.statusBarHeight || 47
const btn = wx.getMenuButtonBoundingClientRect()
navBarHeight.value = (btn && btn.height > 0) ? (btn.top - statusBarHeight.value) * 2 + btn.height : 44
} catch { statusBarHeight.value = 47; navBarHeight.value = 44 }
}
calcNav()
setTimeout(calcNav, 300)
setTimeout(calcNav, 600)
const authStore = useAuthStore()
function signalClass(rssi?: number): string {
if (!rssi || rssi < 30) return 'off'
if (rssi >= 55) return 'good'
if (rssi >= 40) return 'mid'
return 'low'
}
const stats = ref<DashboardData>({ online_cabinets: 0, total_channels: 0, charging_count: 0, idle_channels: 0, full_count: 0, fault_channels: 0, fault_count: 0, today_charge_count: 0, today_energy_kwh: 0, today_charge_hours: 0, alerts: [], projects: [] })
const projects = computed(() => stats.value.projects || [])
const cabinets = ref<CabinetItem[]>([])
const loading = ref(true)
const selectedProjectId = ref<number | null>(null)
const orgs = ref<OrgItem[]>([])
const showOrgPicker = ref(false)
const selectedOrgId = ref<number | null>(null)
const currentOrgName = ref('')
/**
* 加载组织列表并落到「全部」
*
* ⚠️ `GET /api/organizations` 需要 `org:view` 权限,操作员(normal_user)没有该权限 → 403。
* 前端兜底:**无论成功失败都切到「全部」**,失败时清空列表并给出可见提示
* (弹层内有提示行 + toast),绝不静默留一个空弹层。
* 若需让操作员看到自己组织下的设备,需后端提供免 `org:view` 的可见组织接口(前端无法单独解决)。
*/
async function initOrg() {
try {
const res = await h5Api.getOrganizations()
orgs.value = res || []
if (orgs.value.length === 0) {
uni.showToast({ title: '暂无可见组织,已按「全部」展示', icon: 'none' })
}
} catch {
orgs.value = []
uni.showToast({ title: '组织列表加载失败,已按「全部」展示', icon: 'none' })
}
// 统一落到「全部」:只改筛选状态,数据由 onShow 的 fetch 拉取,避免重复请求
applyOrgAll()
}
/** 切到「全部」(仅状态,不含请求) */
function applyOrgAll() { selectedOrgId.value = null; currentOrgName.value = '全部'; selectedProjectId.value = null }
function switchOrgAll() { showOrgPicker.value = false; applyOrgAll(); fetchDashboard(); fetchCabinets() }
function switchOrg(org: OrgItem) { showOrgPicker.value = false; selectedOrgId.value = org.id; currentOrgName.value = org.name; selectedProjectId.value = null; fetchDashboard(); fetchCabinets() }
onShow(async () => {
const scanId = authStore.scanAbstractId
if (scanId) {
authStore.clearScanId()
if (!authStore.isAuthenticated) { uni.navigateTo({ url: '/pages/login/index' }); return }
try { const res = await h5Api.getCabinetByAbstract(scanId); uni.navigateTo({ url: `/pages/device-detail/index?id=${res.id}` }); return } catch { uni.showToast({ title: '设备不存在', icon: 'none' }) }
}
if (!authStore.isAuthenticated) { uni.navigateTo({ url: '/pages/login/index' }); return }
initOrg(); fetchDashboard(); fetchCabinets()
})
onPullDownRefresh(async () => { await Promise.all([fetchDashboard(), fetchCabinets()]); uni.stopPullDownRefresh() })
async function fetchDashboard() {
try { const params: Record<string, string> = {}; if (selectedOrgId.value) params.org_id = String(selectedOrgId.value); const res = await h5Api.getDashboard(params); stats.value = res } catch {}
}
async function fetchCabinets() {
loading.value = true
try { const params: Record<string, any> = {}; if (selectedProjectId.value !== null) params.project_id = selectedProjectId.value; if (selectedOrgId.value) params.org_id = selectedOrgId.value; const res = await h5Api.getCabinets(Object.keys(params).length > 0 ? params : undefined); cabinets.value = res || [] } catch { cabinets.value = [] } finally { loading.value = false }
}
function selectProject(id: number | null) { selectedProjectId.value = id; fetchCabinets() }
function goDetail(id: number) { uni.navigateTo({ url: `/pages/device-detail/index?id=${id}` }) }
function handleScan() {
uni.scanCode({
scanType: ['qrCode'],
success: (res) => {
const code = res.result.trim()
const match = code.match(/\/(\d{2}-\d{8})$/) || code.match(/[?&]id=(\d{2}-\d{8})/)
const abstractId = match ? match[1] : code
if (/^\d{2}-\d{8}$/.test(abstractId)) { goToDeviceByScan(abstractId) }
else { uni.showToast({ title: '无效的二维码', icon: 'none' }) }
},
fail: () => { showScanInput() }
})
}
async function goToDeviceByScan(code: string) {
try { const result = await h5Api.scanCabinet(code); uni.navigateTo({ url: `/pages/device-detail/index?id=${result.id}` }) } catch (err: any) {
const msg = err?.message || ''
if (msg.includes('没有权限') || msg.includes('403') || msg.includes('Forbidden')) { uni.showToast({ title: '没有权限访问该设备', icon: 'none' }) } else if (msg.includes('不存在')) { uni.showToast({ title: '设备不存在', icon: 'none' }) } else { uni.showToast({ title: '扫码失败: ' + msg, icon: 'none' }) }
}
}
function showScanInput() {
uni.showModal({ title: '扫码', content: '请输入或粘贴二维码中的设备编号', editable: true, placeholderText: '如: 10-00000000', success: async (res) => { if (!res.confirm || !res.content?.trim()) return; goToDeviceByScan(res.content.trim()) } })
}
</script>
<style scoped>
.home-page { background: #f5f7f8; min-height: 100vh; }
.top-bar { display: flex; align-items: center; justify-content: space-between; padding: 0 20px; background: #fff; border-bottom: 1px solid #f1f5f9; position: sticky; top: 0; z-index: 100; box-sizing: border-box; }
.org-switcher { display: flex; align-items: center; gap: 6px; padding: 6px 12px; background: #f1f5f9; border-radius: 999px; }
.org-label { font-size: 14px; font-weight: 500; color: #111827; max-width: 160px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.org-arrow { display: flex; align-items: center; }
.scan-btn { display: flex; align-items: center; justify-content: center; background: none; color: #111827; border: none; padding: 0; }
.project-filter { padding: 12px 20px; background: #fff; border-bottom: 1px solid #f1f5f9; overflow-x: auto; }
.project-filter::-webkit-scrollbar { display: none; }
.filter-inner { display: flex; gap: 8px; white-space: nowrap; }
.filter-tag { flex-shrink: 0; padding: 6px 14px; font-size: 13px; border-radius: 999px; border: 1px solid #e2e8f0; color: #475569; background: #fff; }
.filter-tag.active { background: #0d9488; color: #fff; border-color: #0d9488; }
.stats-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; padding: 16px 20px; }
.stat-item { background: #fff; border-radius: 12px; padding: 16px 12px; text-align: center; box-shadow: 0 1px 2px rgba(0,0,0,0.04); }
.stat-value { font-size: 22px; font-weight: 600; font-variant-numeric: tabular-nums; line-height: 1.1; display: block; }
.stat-value.primary { color: #0d9488; }
.stat-value.success { color: #22c55e; }
.stat-value.warning { color: #f59e0b; }
.stat-value.danger { color: #ef4444; }
.stat-value.muted { color: #94a3b8; }
.stat-label { font-size: 12px; color: #94a3b8; margin-top: 4px; }
.section-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 20px; }
.section-title { font-size: 16px; font-weight: 600; color: #111827; }
.section-count { font-size: 12px; color: #94a3b8; }
.device-list { padding: 0 20px; display: flex; flex-direction: column; gap: 12px; }
.device-card { background: #fff; border-radius: 12px; padding: 16px; box-shadow: 0 1px 2px rgba(0,0,0,0.04); }
.device-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; }
.device-id-row { display: flex; align-items: center; gap: 8px; }
.device-id { font-size: 14px; font-weight: 600; color: #111827; font-variant-numeric: tabular-nums; letter-spacing: 0.02em; }
.status-badge { display: inline-flex; align-items: center; gap: 4px; padding: 3px 8px; font-size: 11px; font-weight: 500; border-radius: 999px; }
.status-badge.online { background: #f0fdf4; color: #22c55e; }
.status-badge.offline { background: #f1f5f9; color: #94a3b8; }
.status-badge.online::before, .status-badge.offline::before { content: ''; width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
.temp-badge { font-size: 12px; color: #475569; font-variant-numeric: tabular-nums; flex-shrink: 0; }
.header-right { display: flex; align-items: center; gap: 8px; }
.signal-inline { display: flex; align-items: center; gap: 3px; }
.signal { display: flex; align-items: flex-end; gap: 1.5px; }
.signal-bar { width: 3px; border-radius: 1px; }
.signal-bar.l1 { height: 4px; } .signal-bar.l2 { height: 7px; } .signal-bar.l3 { height: 10px; } .signal-bar.l4 { height: 13px; } .signal-bar.l5 { height: 16px; }
.signal.good .signal-bar { background: #16a34a; } .signal.mid .signal-bar { background: #f59e0b; } .signal.low .signal-bar { background: #ef4444; } .signal.off .signal-bar { background: #94a3b8; }
.rssi-value { font-size: 11px; color: #94a3b8; font-variant-numeric: tabular-nums; }
.pow-tags { display: flex; gap: 4px; }
.pow-tag { font-size: 10px; font-weight: 500; padding: 2px 6px; border-radius: 3px; letter-spacing: 0.04em; white-space: nowrap; }
.pow-tag.on { background: #dcfce7; color: #16a34a; }
.pow-tag.off { background: #fee2e2; color: #dc2626; }
/* 无数据(null/缺失):灰色,与 PC 端「无数据」语义一致,不表示正常 */
.pow-tag.unknown { background: #f1f5f9; color: #94a3b8; }
.device-name { font-size: 14px; font-weight: 500; color: #111827; display: block; margin-bottom: 4px; }
.device-imei { font-size: 11px; color: #94a3b8; font-variant-numeric: tabular-nums; display: block; margin-bottom: 4px; opacity: 0.7; }
.device-addr { font-size: 12px; color: #475569; display: block; margin-bottom: 8px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.channel-stats { display: flex; align-items: center; gap: 12px; padding-top: 12px; border-top: 1px solid #f1f5f9; }
.channel-chip { display: inline-flex; align-items: center; gap: 4px; font-size: 13px; color: #475569; }
.channel-chip.total { color: #6b7280; }
.channel-chip.idle { color: #16a34a; }
.channel-chip.charging { color: #0f766e; }
.channel-chip.full { color: #d97706; }
.channel-chip.fault { color: #dc2626; }
.chip-num { font-variant-numeric: tabular-nums; font-weight: 600; color: #111827; }
.empty-state { text-align: center; padding: 64px 20px; color: #94a3b8; }
.empty-text { font-size: 14px; }
.org-overlay { position: fixed; inset: 0; z-index: 200; background: rgba(0,0,0,0.45); display: flex; align-items: flex-end; justify-content: center; }
.org-sheet { background: #fff; width: 100%; max-width: 430px; max-height: 70vh; border-radius: 16px 16px 0 0; display: flex; flex-direction: column; overflow: hidden; }
.org-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-bottom: 1px solid #f1f5f9; }
.org-title { font-size: 16px; font-weight: 600; }
.org-close { width: 28px; height: 28px; border-radius: 50%; background: #f1f5f9; display: flex; align-items: center; justify-content: center; font-size: 14px; color: #475569; }
.org-list { flex: 1; }
.org-empty { padding: 14px 20px; font-size: 12px; line-height: 18px; color: #94a3b8; background: #f8fafc; }
.org-option { display: flex; align-items: center; justify-content: space-between; padding: 14px 20px; font-size: 15px; color: #111827; border-bottom: 1px solid #f1f5f9; }
.org-option.selected { color: #0d9488; font-weight: 500; }
.org-option.selected::after { content: '✓'; font-size: 16px; font-weight: 600; }
</style>