简介:这份《致远OA V8.1 数据字典》面向致远OA实施、二次开发、数据库运维及系统集成人员,用于快速查阅V8.1版本的表结构与字段定义,降低数据库理解、接口对接和排错成本。压缩包共1个PDF文件,约2.2MB,内容以数据字典表格为主,集中整理表名、字段名、数据类型、是否强制、注释等关键信息,便于在开发联调或升级迁移时定位字段含义。文档示例覆盖ADDRESSBOOK等通讯录相关表,并对ID、MEMBER_ID、CREATE_DATE、UPDATE_DATE以及文本、数字、日期、枚举、选人、选部门、选岗位等扩展字段作了类型与用途说明,可帮助读者理解主键约束、可空规则和业务扩展设计。已有2611人学习下载,适合需要核对字段、梳理表关系或编写SQL与接口文档的技术人员参考。
1. 接手一套跑了三年的 OA 系统,最怕的不是源码看不懂
最怕的是打开数据库,看到通讯录表里排到EXT_ATTR_70的七十个扩展字段,全组没人说得清哪几个还在被业务用。致远OA V8.1 数据字典这份资料解决的就是这件事:它把通讯录、代理、智能处理、考勤、讨论区五大模块近百张表的字段名、数据类型、是否强制、中文注释逐条列了出来,连EXT_ATTR_11是DECIMAL(19,4)这种细节都没漏。适合三类人:要做二开对接的、要写报表取数的、接手历史系统想做字段级影响分析的。下面按「怎么读、怎么查、坑在哪」的顺序拆,重点放在能直接抄去跑的 SQL 和脚本上。
2. 先分组再查表:把字典按「表族—主表—扩展字段」三层拆开
字典正文是按表名字母序排的,字母序天然会把业务关系打散。直接从头往下读,读到ATTENDANCE_DAY_STATISTICS时早就忘了ATTENDANCE_ARRANGE是干什么的。正确读法是先分组,再在组内找父子关系。
2.1 五组前缀把近百张表切成可读单元
把字典里出现的表按前缀归一下,能立刻看出模块边界。这几组是我在实际项目里最常打交道的:
| 表族前缀 | 表数量 | 所属模块 | 主键特征 | 关键关联键 |
|---|---|---|---|---|
ADDRESSBOOK | 6 | 通讯录 | ID BIGINT | MEMBER_ID、TEAM_ID、ADDRESSBOOK_SET_ID |
AGENT | 3 | 代理与离职交接 | ID BIGINT | AGENT_ID、AGENT_TO_ID |
AI_ | 6 | 智能处理/催办/组织变更 | ID BIGINT | TEMPLATE_ID、AFFAIR_ID、MEMBER_ID |
ATTENDANCE_ | 14 | 考勤 | ID BIGINT | ATTENDANCE_TEAM_ID、ATTENDANCE_ARRANGE_ID、ATTENDANCE_CLASS_ID |
BBS_ | 4 | 讨论区 | ID BIGINT | V3X_BBS_ARTICLE_ID、V3X_BBS_BOARD_ID |
分组之后有个规律很明显:所有表的主键统一是BIGINT且强制非空,没有复合主键、没有自增声明、也没有外键约束。这意味着主键值由应用层生成,跨库迁移时不能指望数据库自增帮你补号,做数据同步必须把ID一起带过去。
第二层是组内找角色。以通讯录为例,ADDRESSBOOK_MEMBER存外部联系人本体,ADDRESSBOOK_SET存这个单位的通讯录显示配置,ADDRESSBOOK_SET_SCOPE存配置作用在哪些组织实体上,ADDRESSBOOK_TEAM是个人组/私人通讯录,ADDRESSBOOK_TEAM_MEMBERS是组与成员的关联,ADDRESSBOOK则是给人员挂扩展属性的那张表。这个结构一看就懂:配置、范围、实体、关系、扩展五种角色各占一张。
辨识角色的实操办法是看哪张表带_SET、_SCOPE、_MEMBERS、_DETAIL后缀。带_SCOPE的一定是多对多展开表,带_DETAIL的是主表的下钻明细,带_MEMBERS的是关联表且通常只有两三个业务字段。
提示:
ADDRESSBOOK_SET_SCOPE里的USER_ID/USER_TYPE存的其实是「组织模型实体 ID / 实体类型」,不只是人员。字段名带 USER 但语义是任意实体,这种命名是这套字典里最容易读错的一类。
2.2 EXT_ATTR 的七段分区:为什么 1-10 是 VARCHAR(1024)
ADDRESSBOOK表最扎眼的就是七十个扩展字段。它们不是随便排的,按类型严格切成七段,每段十个:
| 区间 | 数据类型 | 数量 | 语义 | 写入注意 |
|---|---|---|---|---|
EXT_ATTR_1~10 | VARCHAR(1024) | 10 | 文本扩展 | 长度上限 1024 字符,超长直接截断或报错 |
EXT_ATTR_11~20 | DECIMAL(19,4) | 10 | 数字扩展 | 四位小数,整数位最多 15 位 |
EXT_ATTR_21~30 | DATETIME | 10 | 日期扩展 | 无时区信息,跨时区部署要统一约定 |
EXT_ATTR_31~40 | VARCHAR(50) | 10 | 枚举扩展 | 存的是枚举编码不是显示文本 |
EXT_ATTR_41~50 | VARCHAR(50) | 10 | 选人扩展 | 大概率存人员 ID,需实测确认 |
EXT_ATTR_51~60 | VARCHAR(50) | 10 | 选部门扩展 | 同上,存组织 ID |
EXT_ATTR_61~70 | VARCHAR(50) | 10 | 选岗位扩展 | 同上 |
这张表的价值在于:它把「业务自定义字段」和「物理列」做了固定映射。二开时新增一个自定义属性,不是加列,而是占用一个空闲的EXT_ATTR_n,同时在应用层配置元数据。所以排查线上问题时,第一件事是找到元数据配置,看某个业务字段到底落在了第几号扩展列上,而不是在代码里全局搜字段名。
判断某个扩展列有没有被用,最土也最准的办法是数非空行数:
-- 统计 ADDRESSBOOK 表 70 个扩展列的实际使用率 -- 参数说明:把 :TENANT 换成目标单位 ID 条件(该表无单位字段时可去掉 WHERE) SELECT 'EXT_ATTR_1' AS col_name, COUNT(EXT_ATTR_1) AS non_null_rows FROM ADDRESSBOOK UNION ALL SELECT 'EXT_ATTR_2', COUNT(EXT_ATTR_2) FROM ADDRESSBOOK UNION ALL SELECT 'EXT_ATTR_11', COUNT(EXT_ATTR_11) FROM ADDRESSBOOK UNION ALL SELECT 'EXT_ATTR_21', COUNT(EXT_ATTR_21) FROM ADDRESSBOOK UNION ALL SELECT 'EXT_ATTR_41', COUNT(EXT_ATTR_41) FROM ADDRESSBOOK UNION ALL SELECT 'EXT_ATTR_70', COUNT(EXT_ATTR_70) FROM ADDRESSBOOK ORDER BY non_null_rows DESC;COUNT(列名)会跳过 NULL,得到的就是该列有值的行数。真正排查时把七十个列全部展开,一次性看出哪些列常年为 0。连续为 0 的那批基本可以判定为预留未启用,二开时优先从这些列里挑,比另建扩展表省事得多。
如果不想手写七十行 UNION,用 Python 拼一下更省事:
import re # 从字典正文里抽出 ADDRESSBOOK 的字段定义行 raw = """ EXT_ATTR_1 VARCHAR(1024) FALSE 文本类型扩展字段 1 EXT_ATTR_11 DECIMAL(19,4) FALSE 数字类型扩展字段 1 EXT_ATTR_41 VARCHAR(50) FALSE 选人类型数据字段 EXT_ATTR_70 VARCHAR(50) FALSE 选岗位类型数据字段 """ pattern = re.compile(r"^(EXT_ATTR_\d+)\s+(\S+)\s+(TRUE|FALSE)\s+(.+)$", re.M) rows = pattern.findall(raw) # 按序号排序,方便核对分区是否连续 rows.sort(key=lambda r: int(r[0].split("_")[-1])) sql_parts = [] for name, dtype, required, comment in rows: sql_parts.append(f"SELECT '{name}' AS col_name, COUNT({name}) AS non_null_rows FROM ADDRESSBOOK") print(" UNION ALL ".join(sql_parts) + ";")正则里\S+匹配类型串(能吃掉DECIMAL(19,4)这种带括号和逗号的),(TRUE|FALSE)捕获强制标记,(.+)$收下中文注释。排序用int()转序号,避免字符串排序把EXT_ATTR_11排到EXT_ATTR_2前面。
2.3 用 information_schema 反查,把字典和实际库对齐
字典是静态文档,实际库可能已经被人手动加过列或者改过长度。上线前一定要做一次比对,直接查元数据:
-- 取指定表的实际列定义,用于和字典逐行比对 SELECT c.COLUMN_NAME, c.DATA_TYPE, c.CHARACTER_MAXIMUM_LENGTH, c.NUMERIC_PRECISION, c.NUMERIC_SCALE, c.IS_NULLABLE, c.COLUMN_COMMENT FROM information_schema.COLUMNS c WHERE c.TABLE_SCHEMA = DATABASE() AND c.TABLE_NAME IN ('ADDRESSBOOK', 'ADDRESSBOOK_MEMBER', 'AGENT', 'ATTENDANCE_INFO') ORDER BY c.TABLE_NAME, c.ORDINAL_POSITION;ORDINAL_POSITION排序很关键,扩展字段必须按物理顺序排,否则和字典对照时会错位。IS_NULLABLE一列直接对应字典里的强制列——字典标FALSE的字段,这里应该读出YES(允许为空)。如果某一列字典写TRUE(强制)而库里读出YES,说明约束只做在应用层,数据库没有兜底,导入数据时可能出现脏值。
比对结果建议直接落到一张临时表里做差集,而不是肉眼扫。常见不一致有三种:库里多了字典没有的列(后期手改)、长度不一致(比如VARCHAR(50)被改成VARCHAR(255))、注释为空。第三种最麻烦,因为注释是字典最有价值的部分,注释丢了等于字段语义丢了。
3. 通讯录与代理:从 ADDRESSBOOK_MEMBER 到 AGENT 的联查路径
通讯录和代理是这套系统里关联最紧的两个模块。代理设置本质是「A 在某个时间段内替 B 处理某些应用的事项」,而 B 是谁、在哪个部门、属于哪个单位,全靠通讯录那几张表兜住。
3.1 ADDRESSBOOK_MEMBER 的冗余字段:CREATOR_NAME 为什么要存一份
ADDRESSBOOK_MEMBER里有一组值得注意的冗余:CREATOR_ID和CREATOR_NAME同时存在,而且两个都是强制非空。同理COMPANY_NAME、COMPANY_DEPT、COMPANY_POST这些「单位名称/部门/岗位」也都是直接存字符串,而不是只存一个 ID 去关联组织表。
这不是设计失误,是典型的历史取舍:通讯录里的外部联系人,其所属单位很可能根本不在本系统的组织架构里,没有 ID 可关联。所以只能存快照文本。真正需要警惕的是反过来——COMPANY_DEPT VARCHAR(50)和COMPANY_POST VARCHAR(50)只有 50 字符,如果组织名称普遍超过 25 个汉字,插入时就会被截断,而且很多数据库在非严格模式下是静默截断,不报错。
排查这类问题有个简单办法,统计长度分布:
-- 找出接近字段长度上限的记录,判断是否存在截断风险 SELECT ID, NAME, COMPANY_NAME, CHAR_LENGTH(COMPANY_NAME) AS name_len, COMPANY_POST, CHAR_LENGTH(COMPANY_POST) AS post_len FROM ADDRESSBOOK_MEMBER WHERE CHAR_LENGTH(COMPANY_NAME) >= 40 OR CHAR_LENGTH(COMPANY_POST) >= 40 ORDER BY name_len DESC LIMIT 50;用CHAR_LENGTH而不是LENGTH,因为后者按字节算,一个汉字在 UTF-8 里占 3 字节,VARCHAR(50)在多数数据库里是按字符计,混用两个函数会得出完全相反的结论。阈值取 40 是留了余量——真到了 50 就已经写不进去了。
3.2 ADDRESSBOOK_SET 与 SET_SCOPE:可见范围是怎么算出来的
通讯录的可见性配置拆成了两张表。ADDRESSBOOK_SET存配置主体,里面几个字段值得逐个看:
VIEW_SCOPE SMALLINT:查看范围,决定整体策略KEY_INFO SMALLINT/KEY_INFO_TYPE SMALLINT:关键信息开关,类型 1 是手机号+职务级别,2 是手机号,3 是职务级别EXTERNAL_SCOPE SMALLINT:编外人员权限,1 按工作范围显示,2 全部隐藏DISPLAY_COLUMN VARCHAR(1024):显示列头,存的是列标识串M3_SET/M3_DISPLAY_COLUMN:移动端单独一套显示字段ZX_SET/ZX_DISPLAY_COLUMN:另一个客户端单独一套DEPARTMENT_INFO_ORDER SMALLINT:部门下部门和人员的显示先后
M3_*和ZX_*这两组字段说明了一个现实:同一个通讯录,不同端的字段展示是可以不一样的。写接口时如果只读DISPLAY_COLUMN,移动端拿到的列可能和 PC 端对不上。这个坑我在对接第三方门户时踩过一次,对方在手机上打开通讯录,返回的列比预期少了三个字段。
范围本身落在ADDRESSBOOK_SET_SCOPE,靠ADDRESSBOOK_SET_TYPE区分这条范围记录属于哪类配置:1 是查看范围,2 是关键信息,3 是导出与打印权限。取某个用户能看到谁,逻辑是先把用户所属的组织实体(单位/部门/岗位/人员)全部展开,再去匹配 scope 表里的USER_ID+USER_TYPE:
-- 取某条通讯录配置下,所有被授权查看的组织实体 -- 参数 :SET_ID 为 ADDRESSBOOK_SET.ID,:SCOPE_TYPE 取 1/2/3 SELECT s.ID AS set_id, s.VIEW_SCOPE, sc.ADDRESSBOOK_SET_TYPE, sc.USER_TYPE, sc.USER_ID FROM ADDRESSBOOK_SET s JOIN ADDRESSBOOK_SET_SCOPE sc ON sc.ADDRESSBOOK_SET_ID = s.ID WHERE s.ID = :SET_ID AND sc.ADDRESSBOOK_SET_TYPE = :SCOPE_TYPE ORDER BY sc.USER_TYPE, sc.USER_ID;USER_TYPE是字符串不是数字,取值形如ACCOUNT、DEPARTMENT这类大写标识,和AGENT_SCOPE.ENTITY_TYPE的取值风格一致。写死字符串比较时注意大小写,不同环境的排序规则可能不区分大小写,但代码里做等值判断时是区分的。
3.3 AGENT 四表联查:代理、明细、范围、被代理人
代理模块一共四张表,职责切得很清楚:
| 表 | 职责 | 关键字段 |
|---|---|---|
AGENT | 代理关系主体 | AGENT_ID代理人、AGENT_TO_ID被代理人、START_DATE/END_DATE、CANCEL_FLAG、AGENT_TYPE |
AGENT_DETAIL | 代理哪些应用 | AGENT_ID、APP、ENTITY_ID |
AGENT_SCOPE | 代理的发起人范围 | AGENT_ID、ENTITY_TYPE、ENTITY_ID |
AI_DEAL_CONDITION | 自动处理条件 | TEMPLATE_ID、SENDER_ID、USER_ID、DEAL_TYPE |
AGENT里两个标记位要一起看:CANCEL_FLAG是 0 未取消、1 已取消,CANCEL_DATE是取消时间。理论上两者应该同步,实际系统里经常出现CANCEL_FLAG = 1但CANCEL_DATE为 NULL 的历史数据(早期版本没写这个字段),反过来也有。判断代理是否生效,稳妥做法是三个条件一起卡:未取消、已到开始时间、未过结束时间。
AGENT_TYPE区分 1 代理设置、2 离职交接。这两个业务场景共用一张表,但语义差别很大——离职交接通常是一次性的、有明确的批量范围,代理设置则是周期性的。统计代理数量时必须按AGENT_TYPE分开算,否则离职交接的数据会把代理报表撑得很难看。
AGENT_OPERATION有三个取值:0 被代理人自己操作、1 集团管理员、2 单位管理员。这个字段决定了操作来源,审计场景下必须带上。
联查 SQL:
-- 拉出当前生效的代理关系及其应用范围和发起人范围 -- 参数::NOW 传入当前时间,避免数据库时区与应用不一致 SELECT a.ID AS agent_pk, a.AGENT_ID AS agent_user_id, a.AGENT_TO_ID AS target_user_id, a.AGENT_TYPE, a.AGENT_OPERATION, a.START_DATE, a.END_DATE, d.APP AS app_code, d.ENTITY_ID AS app_entity_id, sc.ENTITY_TYPE AS scope_entity_type, sc.ENTITY_ID AS scope_entity_id FROM AGENT a LEFT JOIN AGENT_DETAIL d ON d.AGENT_ID = a.ID LEFT JOIN AGENT_SCOPE sc ON sc.AGENT_ID = a.ID WHERE a.CANCEL_FLAG = 0 AND a.START_DATE <= :NOW AND (a.END_DATE IS NULL OR a.END_DATE >= :NOW) ORDER BY a.AGENT_ID, d.APP;这里用LEFT JOIN而不是INNER JOIN是有意的:AGENT_DETAIL和AGENT_SCOPE都可能为空——代理设置了但没选具体应用,或者应用范围是全量所以不落范围表。用内连接会把这类「看似不完整但实际有效」的代理关系直接过滤掉,结果就是线上明明有代理,报表里查不到。
MATTER_TYPE那个字段(0 按处理人设置、1 按发起人设置,默认 1)决定的是事项条件的作用方向。做代理规则命中测试时,如果发现代理不生效,先确认这个值是 0 还是 1,再看AGENT_SCOPE里存的是发起人还是处理人。
3.4 造一条代理数据回读验证
改完配置不确定有没有生效,最快的验证是直接造一条边界数据再读回来。找一个开始时间刚好等于当前时刻、结束时间为 NULL 的代理记录:
-- 造一条最小可用的代理记录用于验证读取逻辑(测试库执行) INSERT INTO AGENT (ID, AGENT_ID, AGENT_TO_ID, CREATE_DATE, START_DATE, END_DATE, CANCEL_DATE, AGENT_OPTION, CANCEL_FLAG, AGENT_REMIND, AGENT_TO_REMIND, AGENT_TYPE, AGENT_ACCOUNT_ID, AGENT_TO_ACCOUNT_ID, AGENT_OPERATION, MATTER_TYPE) VALUES (900001, 1001, 1002, NOW(), NOW(), NULL, NULL, 'all', 0, 0, 0, 1, 10, 10, 0, 1);注意ID是手工指定的。前面说过这套系统主键不靠自增,如果随手写个NULL或0,很可能和应用层生成的雪花 ID 撞上,轻则插入失败,重则覆盖别的记录。测试环境造数据时,ID 段建议统一用 9 开头并做记录。
插完再跑一遍 3.3 的联查,确认这条记录出现在结果里,同时确认AGENT_DETAIL和AGENT_SCOPE为空时LEFT JOIN仍然保留了主记录。这一轮走完,代理读取逻辑基本就验证透了。
4. 考勤取数:从 ATTENDANCE_TEAM 到 ATTENDANCE_DAY_STATISTICS
考勤是这套字典里表最多的一组,十四张表。刚接触时容易一头雾水,其实抓住一条主线就顺了:考勤组 → 排班 → 班次 → 固定时间 → 工作日 → 打卡记录 → 日统计。
4.1 排班、班次、工作日、固定时间四张表的父子关系
这四张表靠三个外键串起来,理解顺序不能乱:
| 表 | 父级字段 | 作用 |
|---|---|---|
ATTENDANCE_TEAM | 无(顶层) | 考勤组,配置的归属单位 |
ATTENDANCE_ARRANGE | ATTENDANCE_TEAM_ID | 排班,ARRANGE_TYPE0 固定排班 / 1 周期排班 |
ATTENDANCE_CLASS | ATTENDANCE_ARRANGE_ID、ATTENDANCE_TEAM_ID | 班次,CLASS_TYPE0 一天一次 / 1 一天两次 / 2 一天三次 |
ATTENDANCE_DAY | ATTENDANCE_ARRANGE_ID、ATTENDANCE_CLASS_ID、ATTENDANCE_TEAM_ID | 按星期几配班次,IS_WORK0 非工作日 / 1 工作日 |
ATTENDANCE_FIX_TIME | ATTENDANCE_CLASS_ID、ATTENDANCE_ARRANGE_ID、ATTENDANCE_TEAM_ID | 班次的具体时间点 |
ATTENDANCE_CLASS里除了名称和类型,还塞了一堆弹性规则参数:FLEX_TIME弹性时间、LATE_SERIOUS严重迟到阈值、ABSENT_WORK旷工阈值、LEAVE_FIX_TIME晚走多少时间、ATTEND_FIX_TIME晚到多少时间。这五个字段全部是VARCHAR(50)而不是时间或数字类型,说明它们存的很可能是带单位的配置串(比如30或00:30),读取时要做解析。这一点在写报表时特别容易翻车——直接拿去和数字比大小,类型转换会静默失败。
ATTENDANCE_FIX_TIME存的是具体时刻:WORK_TIME上班时间、END_TIME下班时间、EARLY_WORK_TIME最早可签到时间、LAST_END_TIME最晚可签退时间,外加ATTEND_REMIND/LEAVE_REMIND两个提醒开关。一条班次下可能有多条固定时间记录(一天两次卡就是两条),所以取「这个班次几点上班」不能LIMIT 1,要按顺序取。
ATTENDANCE_DAY是排班和具体日期的桥接:一行代表「某个排班下,星期几,上哪个班次,算不算工作日」。周期排班就是靠它落地的。查某个人某天该不该上班,路径是人员 → 考勤组 → 排班 → ATTENDANCE_DAY(WEEK_DAY) → IS_WORK。
4.2 ATTENDANCE_INFO 和 ATTENDANCE_HISTORY:两张签到表的差异
这两张表字段高度重合,第一次看会以为是历史表和当前表的关系。逐字段比对后发现差异不在「时间新旧」,而在「状态和元数据」:
| 差异字段 | ATTENDANCE_INFO | ATTENDANCE_HISTORY |
|---|---|---|
SIGN长度 | VARCHAR(2000) | VARCHAR(500) |
STATE | 有,3 上班迟到 / 4 下班早退 / 5 正常上班 / 6 正常下班 | 无 |
MODIFY_NUM | 有,签到修改次数 | 无 |
DEVICE_INFO | 有,设备 ID | 无 |
MAC_ADDRESS | 有,WIFI MAC | 无 |
IS_WORK_DAY | 有,1 是 / 0 否 | 无 |
PUNCH_TYPE | 有,排班类型 | 无 |
IMG_INFO/RECORD_INFO | 上传的 JSON 格式 | 附件表外的相关信息 |
结论比较清楚:ATTENDANCE_INFO是带状态判定的主记录,ATTENDANCE_HISTORY更像是轻量的原始存档或者某个特定采集渠道的落地表——没有STATE说明它不参与迟到早退判定,没有DEVICE_INFO说明它不追踪设备。
判断某条打卡该读哪张表,看数据里有没有STATE值就行。做统计时如果两张都扫,同一次打卡会被算两遍,出勤天数直接翻倍。这个坑我在一个补卡统计里踩过,当时两张表都写了 UNION,结果某人当月正常出勤 22 天算成了 44 天,对着数据核了一下午。
两张表都有的地理字段值得单独提一句:LONGITUDE/LATITUDE是VARCHAR(50),CONTINENT/COUNTRY/PROVINCE/CITY/TOWN/STREET一路到ADDRESS_CODE也都是字符串。经纬度存字符串意味着没法直接做距离计算,要做围栏判断只能先转换或者用应用层算。ADDRESS_TYPE说明了地址来源:1 表示 POI、2 表示街道、3 表示路、4 是其他。
ATTENDANCE_SETTING是围栏配置表,LONGITUDE/LATITUDE+ATTEND_RANGE定义了圆心和半径,RELATIVE_ID存第三方系统导入的围栏数据 ID。做外勤合规校验时,就是拿打卡点去和这张表算距离。
4.3 日统计字段的口径:别把 AB_NORMAL_DAY 当异常天数看
ATTENDANCE_DAY_STATISTICS是汇总表,字段多但口径要一个个抠:
| 字段 | 含义 | 口径提醒 |
|---|---|---|
NORMAL_DAYS | 出勤(正常)天数 | 不含迟到早退,只算完全正常 |
AB_NORMAL_DAY | 异常天数 | 迟到/早退/缺卡的合计,分母要看PUNCH_CLASS |
NONWORKDAYS_DAY | 非工作打卡天数 | 加班口径,不算异常 |
LATE_NUM | 迟到次数 | 按次不按天,一天两次卡可能算两次 |
LEAVE_EARLY_NUM | 早退次数 | 同上 |
OUTSIDE_NUM | 外勤次数 | 和ATTENDANCE_INFO.TYPE = 3对应 |
MISSINGCLOCK_COUNT | 缺卡总数 | 等于上班缺卡 + 下班缺卡 |
WORKING_MISSINGCLOCK | 上班缺卡数 | 早卡缺失 |
KNOCKOFF_MISSINGCLOCK | 下班缺卡数 | 晚卡缺失 |
PUNCH_CLASS | 应打卡次数 | 1 一天一次,2 一天两次 |
PUNCH_CLASS是分母,AB_NORMAL_DAY是分子的一部分。算异常率时如果忘了除PUNCH_CLASS,一天一次卡和一天两次卡的人会被放在同一个尺度上比,结果毫无意义。
LEAVE_EARLY_NUM这个字段名也值得记一笔:全表其他地方都写LEAVE_EARLY,只有这里是_NUM后缀,而LATE_NUM也是_NUM。这类命名不统一在写动态 SQL 时最容易出错——按_NUM$批量匹配字段,会把MISSINGCLOCK_COUNT漏掉,因为它用的是_COUNT。
4.4 一条 SQL 拉出某人某月的考勤明细和汇总
先取明细。按天聚合ATTENDANCE_INFO,把一天里最早的一次签到和最晚的一次签退拿出来:
-- 按人按天聚合原始打卡,:START / :END 为半开区间 [START, END) SELECT OWNER_ID, PUNCH_DATE, MIN(CASE WHEN TYPES = 1 THEN SIGN_TIME END) AS first_in, MAX(CASE WHEN TYPES = 2 THEN SIGN_TIME END) AS last_out, COUNT(*) AS punch_cnt, SUM(CASE WHEN STATE IN (3, 4) THEN 1 ELSE 0 END) AS abnormal_cnt, SUM(CASE WHEN TYPES = 3 THEN 1 ELSE 0 END) AS outside_cnt FROM ATTENDANCE_INFO WHERE ACCOUNT_ID = :ACCOUNT AND PUNCH_DATE >= :START AND PUNCH_DATE < :END GROUP BY OWNER_ID, PUNCH_DATE ORDER BY PUNCH_DATE;TYPES的取值是 1 签到、2 签退、3 外勤,用条件聚合就能把三种事件压到一行。SIGN_TIME是实际打卡时刻,PUNCH_DATE是归属的考勤日——这两个不是一回事,夜班场景下凌晨两点打卡可能归属前一天,所以分组必须用PUNCH_DATE不能用DATE(SIGN_TIME)。这是夜班考勤统计最常见的错。
再取汇总,直接读日统计表:
-- 取某人某月的日统计汇总 SELECT s.PUNCH_DATE, s.WEEK_DAYS, s.NORMAL_DAYS, s.AB_NORMAL_DAY, s.NONWORKDAYS_DAY, s.LATE_NUM, s.LEAVE_EARLY_NUM, s.OUTSIDE_NUM, s.MISSINGCLOCK_COUNT, s.PUNCH_CLASS, s.CREATE_TIME, s.MODIFY_TIME FROM ATTENDANCE_DAY_STATISTICS s WHERE s.ACCOUNT_ID = :ACCOUNT AND s.MEMBER_ID = :MEMBER AND s.PUNCH_DATE >= :START AND s.PUNCH_DATE < :END ORDER BY s.PUNCH_DATE;两个查询的过滤条件必须保持一致,都是ACCOUNT_ID+ 时间半开区间。用>=和<而不是BETWEEN,是因为DATETIME精度下BETWEEN ... AND '2024-01-31'会把 1 月 31 日 00:00:00 之后的记录全部漏掉——如果时间部分非零就彻底错了。
跑完之后做一次交叉验证:明细表里算出勤的天数,应该和汇总表的NORMAL_DAYS加上异常天数大致对得上。对不上就说明汇总任务没跑或者跑的时候漏了人。这个校验我一般写成固定脚本,每月初跑一次,比等业务方来投诉强。
5. 常见问题排查:五个用错数据字典的翻车现场
5.1 同一字段名在不同表里语义完全相反
现象:讨论区匿名帖列表里,一部分帖子显示成匿名、一部分显示成实名,筛出来的结果和界面对不上。
原因:ANONYMOUS_FLAG这个字段在三张表里的取值约定是反的。BBS_ARTICLE是 0 正常、1 匿名;BBS_ARTICLE_REPLY是 0 匿名、1 否;BBS_BOARD的ANONYMOUS_FLAG是 0 匿名、1 否。同一个字段名,主帖表按「1 表示是」理解,回复表和版块表按「1 表示否」理解。这个不一致没法靠猜,只能逐表看字典注释。
解决:所有涉及匿名的查询都显式写注释,禁止跨表复用同一个判断片段。写视图时给每张表的匿名字段起不同别名,比如article_anon、reply_anon,别名上直接带上语义,后面接手的人不会再犯错。
5.2 SMALLINT 状态码跨模块串用
现象:从组织变更记录表里按「部门」筛数据,结果筛出了岗位和职务级别。
原因:AI_ORG_CHANGE_RECORD.ORG_TYPE是 0 部门、1 岗位、2 职务级别;而ATTENDANCE_AUTH_ENTITY.ENTITY_TYPE是 3 人员、1 部门、0 单位;ADDRESSBOOK_TEAM.TYPE又是 0 系统组、1 个人组、2 私人通讯录。三个SMALLINT、三套完全不同的枚举,而且 1 在三个表里分别表示岗位、部门、个人组。
解决:把这类枚举统一维护成一张映射表放在项目文档里,代码里禁止裸写数字。Java 侧用枚举类,SQL 侧用CASE WHEN显式翻译成文本再输出,别把数字直接吐给前端。
5.3 字段名拼写错的列,写 SQL 时永远查不到
现象:按处理时长过滤自动处理条件,SQL 不报错但结果恒为空。
原因:AI_DEAL_CONDITION里那个字段叫SIGNLE_VIEW_PERIOD,不是SINGLE_VIEW_PERIOD。字典原文就是这么写的,属于历史拼写遗留。手写 SQL 时按正常拼写敲,数据库直接报列不存在;但如果是 ORM 自动映射或者动态拼接,异常被吞掉,就变成静默返回空集。
解决:所有表字段名一律从字典复制,不手敲。做代码生成时把字典当作唯一数据源,字段名常量自动生成,杜绝手写。遇到疑似拼写错误但数据库里确实存在的列,加一行注释标注,别顺手「修正」——改不动,生产环境改列名风险远大于收益。
5.4 字典版本和标题不一致,按标题查表会扑空
现象:拿 V8.1 的字典去找某张表,翻遍了都找不到,最后发现那张表在字典里根本没收。
原因:字典正文开头标的是 V5,标题写的是 V8.1。这种版本错位在小版本迭代快的产品里很常见:基础表结构沿用了 V5 的定义,V8.1 新增的表没补进去。用字典做影响分析时,如果只信标题,会误以为覆盖是全量的。
解决:拿到字典第一件事是用 2.3 的information_schema查询做一次双向差集——库里有的表字典没有、字典有的表库里没有,两个方向都要查。差集结果单独建一张表管理,每次升级前更新一次。别指望一份静态文档能覆盖全部,它只是起点。
5.5 扩展字段用超了长度,静默截断不留痕
现象:通讯录某个自定义文本字段,用户反馈填了一大段备注,保存成功后只剩前一半。
原因:EXT_ATTR_1到EXT_ATTR_10都是VARCHAR(1024)。多个数据库在非严格模式下对超长字符串是截断处理而不是报错,应用层如果不做长度校验,用户完全无感。更隐蔽的是字符集差异——1024 字符在 UTF-8 下最多占 3072 字节,如果某处按字节判断长度,阈值就完全不对了。
解决:应用层在写入前用字符长度校验,别依赖数据库。定期跑一次长度分布统计,把接近上限的记录捞出来看是不是已经被截断。真要存长文本,老老实实新建一张一对多的扩展表,别硬塞进EXT_ATTR。
6. 用字典做字段级影响分析:一个引用扫描脚本加一张变更清单
改字段最怕的不是改错,是改漏。字典给了字段清单,但没告诉你哪个字段被哪些代码引用。我的做法是拿字典当基准,反过来扫代码库,把引用次数统计出来,改之前先看清单。
脚本不复杂,核心是按表名和字段名双向匹配:
import re from pathlib import Path # 需要评估影响的字段清单,从数据字典里抄过来 TARGETS = [ ("ADDRESSBOOK", "EXT_ATTR_41"), ("ADDRESSBOOK_SET", "DISPLAY_COLUMN"), ("AGENT", "CANCEL_FLAG"), ("ATTENDANCE_INFO", "STATE"), ] # 扫描目录下所有 SQL / XML / Java 文件 ROOT = Path("./src") SUFFIX = {".sql", ".xml", ".java", ".py"} hits = {f"{t}.{c}": [] for t, c in TARGETS} for path in ROOT.rglob("*"): if path.suffix not in SUFFIX or not path.is_file(): continue try: text = path.read_text(encoding="utf-8", errors="ignore") except OSError: continue upper = text.upper() for table, col in TARGETS: # 字段名独立出现即计入,避免 ext_attr_4 命中 ext_attr_41 if re.search(rf"\b{col}\b", upper): hits[f"{table}.{col}"].append(str(path)) for key, files in sorted(hits.items(), key=lambda kv: -len(kv[1])): print(f"{key}\t{len(files)} 个文件") for f in files[:5]: print(f" {f}")\b词边界是关键,不加的话EXT_ATTR_4会把EXT_ATTR_41到EXT_ATTR_49全部命中,统计结果虚高十倍。errors="ignore"是为了跳过编码混杂的历史文件,宁可漏几个也别让脚本中途崩掉。
跑完会得到一张引用清单。真正做变更决策时,我还会再补一张人工判断表,把「引用次数」和「业务影响」分开看:
| 字段 | 代码引用文件数 | 是否对外接口返回 | 变更风险 |
|---|---|---|---|
ADDRESSBOOK.EXT_ATTR_41 | 7 | 是 | 高,需同步改三方对接文档 |
ADDRESSBOOK_SET.DISPLAY_COLUMN | 12 | 是 | 高,PC 和移动端各有一套解析 |
AGENT.CANCEL_FLAG | 23 | 否 | 中,内部逻辑,但涉及代理生效判断 |
ATTENDANCE_INFO.STATE | 31 | 是 | 高,迟到早退口径全挂在这上面 |
引用次数高但不对外返回的,改起来相对安全;引用次数中等但出现在接口返回体里的,反而要格外小心,因为下游系统可能已经按老格式解析了。
这套流程走过一次之后我就固定下来了:任何字段变更前,先拿字典圈出候选字段,再跑引用扫描,最后填一遍风险表,三步缺一不可。有次赶时间跳过了扫描,直接改了一个看着只有两处引用的扩展字段,结果上线后第三方考勤机对接全挂——引用藏在配置文件里,脚本扫.properties的时候我没把后缀加进去。从那以后每次跑扫描,后缀列表都要重新对一遍项目结构,宁可多扫一遍也不省这一步。
希望帮到你。
本文还有配套的精品资源,点击获取