设备管不住、工时算不清、工资对不拢——这三个问题叠加,利润流失往往不是单点故障
引言:一张工资条背后的系统性难题
月初发薪日前,某机械租赁公司HR老张对着Excel加班到凌晨两点。40台设备、80名机手,考勤记录散落在纸质签到表、微信报岗和班长手写笔记里。设备保养记录在另一个Excel里,人机对应关系要靠脑记。最终出来的工资条,总有人质疑工时不对、绩效少算、奖惩不明。
这个场景在工程机械、施工总包、环卫市政等行业并非个例。
传统“考勤打卡”工具解决不了这个问题。设备台账乱、保养跟不上、工时算不清、工资条对不拢——这四件事叠加起来,流失的不是一个人一天的工时,而是整个项目的利润底数。
本文分享一个在工程机械现场生长出来的考勤与绩效管理平台——晋辉智机通(项目代号xlhMoreApp)的设计思路。它不是打卡软件加个壳,而是围绕“设备管理”与“工资绩效”两大核心,把考勤作为数据底座,形成人、机、现场一条线的闭环系统。
第一部分:架构设计的起点——四大能力板块的优先级排序
在动手写第一行代码之前,团队花了两周跑工地、蹲租赁公司、跟班组长聊天。发现真正被高频使用的功能,排序完全不同于“打卡软件”的常规设计:
优先级 | 板块 | 核心解决的问题 |
|---|---|---|
★★★ | 设备管理 | 机械台账、机手绑定、保养计划——人机现场一条线 |
★★★ | 工资绩效 | 考勤汇总、工资条、积分规则——算得清、奖得明 |
★★ | 考勤 | GPS打卡、拍照留痕、异常申诉——真实可追溯的底座 |
★ | 员工培养 | 技能提升与积分挂钩——在干活过程中培养 |
这个排序决定了产品设计的底层逻辑:考勤是手段,设备和绩效才是客户真正愿意付费的价值锚点。
第二部分:多租户架构——一套代码,两种交付
2.1 需求来源:两种客户,一种代码库
随着客户增多,需求分叉出现了:
中小型租赁公司:希望快速上线,不想自建机房,按年付费即可
大型国企/集团:数据不能出内网,要求私有化部署,数据完全自主可控
常规做法是维护两套代码——一套SaaS版,一套私有化版。但两套代码意味着双倍维护成本、功能不同步、bug修复要两边改。
解决方案:在数据库层做隔离,同一个Spring Boot工程通过配置切换运行模式:
# application.yml 核心配置 tenant: mode: shared # shared=托管共享库行级隔离 | standalone=私有化独立库shared模式:共享MySQL数据库,MyBatis拦截器自动向SQL追加tenant_id条件,实现行级隔离standalone模式:独立数据库单企业,关闭SQL改写,数据完全在客户环境内
2.2 数据隔离的核心设计
MyBatis拦截器 + JSqlParser 自动改写SQL,业务代码零侵入:
@Intercepts({@Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class})}) public class TenantInterceptor implements Interceptor { @Override public Object intercept(Invocation invocation) throws Throwable { // 获取当前租户ID(从ThreadLocal或请求头X-Tenant-Id) String tenantId = TenantContext.getCurrentTenantId(); if (StringUtils.isNotBlank(tenantId) && !"platform".equals(tenantId)) { // 使用JSqlParser改写SQL,自动追加 AND tenant_id = ? // 仅对业务表生效,系统表跳过 } return invocation.proceed(); } }关键设计决策:不依赖多数据源,而是单数据源+行级隔离。原因是:
托管模式下动态创建数据库不现实(需要DDL权限)
行级隔离让新增租户零成本,无需重复部署整套系统
运维后台开通企业 → 生成tenant_id → 立即可用,全程秒级
第三部分:登录链路与租户识别
多企业版与单企业版最大的体验差异在登录链路。用户不是直接输入账号密码,而是:
输入企业码(如
test、zhangsan-lease)调用
GET /Tenants/resolve?code=xxx获取企业ID、API地址、品牌信息再用企业码+账号密码登录
后续请求通过JWT Token中的tenantId或请求头
X-Tenant-Id传递租户上下文
// 登录接口示例 @PostMapping("/login") public Result login(@RequestBody LoginDTO dto) { // 1. 根据企业码解析租户 Tenant tenant = tenantService.resolveByCode(dto.getTenantCode()); if (tenant == null) { return Result.error("企业码不存在,请联系管理员"); } // 2. 校验账号密码(带租户隔离) User user = userService.login(dto.getUsername(), dto.getPassword(), tenant.getId()); // 3. 生成JWT,包含tenantId String token = JWT.create() .withClaim("userId", user.getId()) .withClaim("tenantId", tenant.getId()) .withClaim("roleId", user.getRoleId()) .sign(Algorithm.HMAC256(tokenSecret)); return Result.ok(token); }运维管理员使用独立标识platform登录,roleId=100,仅用于企业开通与续期管理,不访问业务数据。
第四部分:企业品牌与服务续期的细节设计
4.1 品牌展示的分区域策略
多租户系统面临一个矛盾:既要让企业看到自己的品牌,又要保持平台统一的认知。我们的策略是分区域展示:
区域 | 展示规则 |
|---|---|
| App登录页/注册页 | 固定默认Logo +「晋辉智机通」,不随企业配置变化 |
| 管理后台登录页/左侧侧栏 | 固定默认Logo +「晋辉智机通」 |
| 管理后台登录后顶栏 | 可配置系统名、Logo、品牌备注;14种推荐背景色+自定义色值 |
| App登录后首页 | 展示企业名称;若配置了企业Logo则一并展示 |
这个策略平衡了平台统一认知与企业个性化两个需求。登录前用户看到的是平台品牌(知道自己在用哪个系统),登录后看到的是企业品牌(归属感)。
4.2 服务续期提醒
托管部署模式下,企业服务到期前30天:
移动端自动弹窗提醒
管理后台顶部显示黄色警示条
到期后自动拦截登录,引导联系晋辉续费
私有化部署客户不受此约束,由客户自行运维。
第五部分:前后端工程化实践
5.1 API地址的统一管理
多环境部署容易出现的坑:前端配置了多个API地址,打包时漏改一个就导致生产环境请求失败。
管理后台:utils/apiBase.js统一从环境变量读取:
// .env.production VUE_APP_BASE_URL = 'https://jinhuitech.top/seaHouseApiMore/' // apiBase.js export function getApiBaseUrl() { return process.env.VUE_APP_BASE_URL || '/seaHouseApiMore/'; }移动端:util/request/index.js集中配置:
// 统一API根地址,所有请求通过此入口 const API_BASE_URL = 'https://jinhuitech.top/seaHouseApiMore/'; // 图片URL规范化 export function resolveImageUrl(url) { if (!url) return ''; if (url.startsWith('http://') || url.startsWith('https://')) return url; return API_BASE_URL + 'file/download?url=' + encodeURIComponent(url); }5.2 Nginx反向代理配置
# API反代 location /seaHouseApiMore/ { rewrite ^/seaHouseApiMore/(.*) /$1 break; proxy_pass http://127.0.0.1:11070; proxy_set_header X-Forwarded-Prefix /seaHouseApiMore; proxy_set_header Host $host; } # 管理后台静态资源 location /seahouseAdminMore/ { alias /etc/www/website/seahouseAdminMore/; try_files $uri $uri/ /seahouseAdminMore/index.html; }5.3 打包与部署
# 后端打包 cd move-apiMorePub mvn clean package -DskipTests # 输出 target/xlhMoreApi.jar # 生产启动(SaaS) java -jar target/xlhMoreApi.jar --spring.profiles.active=prod # 私有化启动 java -jar target/xlhMoreApi.jar --spring.profiles.active=prodprivate环境变量通过操作系统注入,不写入配置文件:
export TOKEN_SECRET='至少32位随机字符串' export DB_URL='jdbc:mysql://host:3306/sea_house_more?...' export DB_USERNAME='...' export DB_PASSWORD='...' export OSS_ACCESS_KEY_ID='AK' export OSS_ACCESS_KEY_SECRET='SK'第六部分:关键业务功能实现
6.1 GPS打卡与拍照留痕
移动端打卡时采集GPS经纬度,与后台配置的打卡地点比对,计算距离是否在允许范围内:
// 打卡校验逻辑 public boolean checkLocation(BigDecimal lat, BigDecimal lng, LocationConfig config) { double distance = GeoUtils.distance( lat.doubleValue(), lng.doubleValue(), config.getLatitude(), config.getLongitude() ); return distance <= config.getAllowedRadius(); // 默认300米 }拍照留痕:上传的图片存储到OSS,返回URL,后续打卡记录与图片关联,确保考勤可审计。
6.2 考勤汇总 → 工资条
考勤数据按自然月汇总,计算OT(加班工时)、中直(中班/直落工时):
-- 考勤汇总核心SQL(简化) SELECT user_id, SUM(CASEWHENtype = 'normal'THENhoursELSE0END) as normal_hours, SUM(CASEWHENtype = 'ot'THENhoursELSE0END) as ot_hours, SUM(CASEWHENtype = 'midnight'THENhoursELSE0END) as midnight_hours FROM attendance_records WHERE tenant_id = #{tenantId} ANDmonth = #{month} GROUPBY user_id;汇总结果 → 工资条 → 机手App端可查。
6.3 积分规则与抽奖
积分是绩效激励的重要载体。规则可配置:
-- 积分规则表 CREATE TABLE point_rule ( id BIGINT PRIMARY KEY, tenant_id VARCHAR(32), rule_name VARCHAR(100), points INT, -- 每次奖励积分 max_times INT, -- 每人每月最多次数 status TINYINT -- 启用/停用 );抽奖功能:每N积分抽一次,奖品概率可配,支持增删改,后台自动校验概率总和是否为100%。
// 抽奖逻辑 public Prize drawLottery(Long userId) { // 1. 扣减用户积分 userService.deductPoints(userId, costPoints); // 2. 按概率随机抽取奖品 List<Prize> prizes = prizeService.getEnabledPrizes(); double rand = Math.random(); double cumulative = 0; for (Prize prize : prizes) { cumulative += prize.getProbability(); if (rand <= cumulative) { return prize; } } returnnull; // 实际应兜底返回未中奖 }第七部分:可扩展的场景延伸
虽然产品起源于工程机械现场,但能力和架构决定了它可以延伸到更多场景:
行业 | 核心诉求 | 使用方式 |
|---|---|---|
环卫/市政 | 片区巡检、车辆设备协同 | GPS打卡+任务派单+保养巡检 |
物业/保洁 | 多小区巡更、保洁任务留痕 | 特殊任务+打卡留痕+班长审核 |
仓储/物流 | 班次考勤、计件任务 | 移动端打卡+工时汇总 |
安装维修 | 工单派发、上门打卡、拍照验收 | 工单载体+定位打卡+积分激励 |
第八部分:架构决策复盘
回顾整个设计和开发过程,有几个关键决策值得复盘:
8.1 为什么不用多数据源做租户隔离?
多数据源方案需要为每个租户创建独立数据库,托管模式下不可行(动态创建数据库需要DDL权限,且数据迁移成本高)。行级隔离+SQL改写方案在shared模式下更轻量,新增租户只需insert一条记录。
8.2 为什么JWT不包含租户ID?
初始版本JWT中包含了tenantId,但遇到租户切换的场景(如运维管理员platform需要跨租户操作)。最终方案是JWT只存用户身份,租户上下文通过请求头X-Tenant-Id传递,更灵活。
8.3 私有化部署的企业码怎么处理?
私有化部署虽然只有一家企业,但仍然保留企业码登录链路。这样同一套App可以同时服务于SaaS和私有化客户——私有化客户只需将App的API地址指向自己的服务器,企业码由客户自行分配。
# 私有化部署配置 tenant.mode=standalone tenant.default-tenant-code=your-company app.public-api-base-url=https://your-domain.com/seaHouseApiMore/结语
从一个工地的Excel工资表,到覆盖设备台账、考勤留痕、绩效核算、积分激励的全链路系统,这个平台的设计始终围绕三个词展开:算得清、奖得明、管得住。
技术层面的多租户隔离、SQL改写、JWT鉴权、OSS存储都是手段,真正的价值是让一线机手能实时查工资单、让班组长能快速处理异常、让总部能一张报表看清人机现场。
如果你正在管理分散的工程机械机手、环卫巡检员、物业外勤或仓储班组,这个系统已经准备好了从企业码登录 → 三端实时同步 → 数据隔离 → 部署交付的完整链路。
托管省心,私有化安心。有需要联系晋辉科技,或找你司系统管理员获取企业码。
项目体验:
租户后台:https://jinhuitech.top/seahouseAdminMore/#/login租户 test:admin1234 / newocean123456租户 test2:13222222222 / 123456移动端:test租户:wuyujie / 123456 test2租户:chenjie / 123456安卓apk下载测试地址: https://www.pgyer.com/jinhuizhijitong-android