From cb7dc36cadf48af5a027757cd025479d6bc4d760 Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Mon, 31 Aug 2026 13:27:12 +0800 Subject: [PATCH] feat: add attendance APIs --- docs/IM-前后端对接文档.md | 21 +- docs/IM-数据库变更汇总.sql | 124 +++++- server/db/adapter.go | 5 + server/db/mysql/adapter.go | 129 +++++- server/db/mysql/tenant_adapter.go | 703 +++++++++++++++++++++++++++++- server/im_http.go | 369 +++++++++++++++- server/im_http_test.go | 156 +++++++ server/main.go | 4 + server/store/store.go | 97 +++++ server/store/types/attendance.go | 153 +++++++ 10 files changed, 1749 insertions(+), 12 deletions(-) create mode 100644 server/store/types/attendance.go diff --git a/docs/IM-前后端对接文档.md b/docs/IM-前后端对接文档.md index cf0277a..ed96c98 100644 --- a/docs/IM-前后端对接文档.md +++ b/docs/IM-前后端对接文档.md @@ -2061,6 +2061,17 @@ X-MeetingIM-APIKey: <预置的普通 API Key> - 全局搜索:`GET /v1/im/organization/search?q=&types=department,user&cursor=`。服务端执行组织范围授权,离职用户标记不可联系或从结果移除。 - 组织架构可见范围由服务端按当前登录用户裁剪:上架测试账号可看全部组织;手机号注册账号默认只能看到自己与好友所在组织路径及成员;邀请码注册账号默认只能看到自己所在组织路径及本人。上架测试账号通过租户业务库 `im_org_visibility_overrides` 配置 `visibility_scope=all`,客户端不传可见范围参数。 +### 9.2.1 打卡考勤 + +**状态:已实现基础版。** + +- 打卡规则、班次、位置/Wi-Fi 约束、补卡窗口、异常判定和统计结果必须以后端为真源;客户端只负责展示、采集设备侧上下文和临时离线队列,不能把正式打卡记录或规则只保存在本地。 +- 配置:`GET /v1/im/attendance/config?date=2026-08-31` 返回当前用户当天适用的规则、班次、允许打卡动作和前端展示文案。服务端按租户、部门、人员、节假日和特殊排班裁剪,前端不得自行推导是否迟到/早退/缺卡。 +- 记录:`GET /v1/im/attendance/records?from=2026-08-01&to=2026-08-31&scope=mine` 返回当前用户打卡记录、异常状态和补卡状态;管理员用 `scope=managed&department_id=&user_id=&cursor=` 查询权限范围内记录。 +- 打卡:`POST /v1/im/attendance/checkins`,Body 带 `date,action,rule_id,shift_id,client_time,location,wifi,device`,写操作必须带 `Idempotency-Key`。服务端校验打卡窗口、地理围栏/Wi-Fi、重复提交和设备风险,成功后返回服务端判定状态。 +- 补卡/异常申诉:`POST /v1/im/attendance/appeals` 创建申请,`GET /v1/im/attendance/appeals?scope=mine|managed&status=&cursor=` 查询列表,审批动作由管理端或后续办公审批接口处理。 +- 主动下行:规则变更、打卡状态变化、补卡审批变化可通过 `info.what="im"`,事件 `attendance.rule.updated/attendance.record.updated/attendance.appeal.updated` 下发;前端收到后刷新配置、记录或列表。 + ### 9.3 会议通知-发起人状态.png 会议通知-发起人状态 @@ -2180,15 +2191,21 @@ X-MeetingIM-APIKey: <预置的普通 API Key> - 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。 - 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。 -#### 9.14.1 工作台、组织、会议、日报、月报、信箱 +#### 9.14.1 工作台、组织、打卡、会议、日报、月报、信箱 | 接口 | 输入示例 | 成功 `data` 示例 | | --- | --- | --- | -| `GET /v1/im/workbench` | Query only | `{"layout_version":1,"modules":[{"code":"organization","name":"组织架构","icon_file_id":"","route":"/organization","unread_count":0,"enabled":true,"permission":"enabled"}],"enterprise":{"tenant_code":"default","feature_flags":{"organization":true,"meetings":true}}}` | +| `GET /v1/im/workbench` | Query only | `{"layout_version":1,"modules":[{"code":"organization","name":"组织架构","icon_file_id":"","route":"/organization","unread_count":0,"enabled":true,"permission":"enabled"},{"code":"attendance","name":"打卡考勤","icon_file_id":"","route":"/attendance","unread_count":0,"enabled":true,"permission":"enabled"}],"enterprise":{"tenant_code":"default","feature_flags":{"organization":true,"attendance":true,"meetings":true}}}` | | `GET /v1/im/organization/departments?parent_id=root&cursor=` | Query only | `{"items":[{"department_id":"dep_1","name":"研发部","parent_id":"root","child_count":0,"member_count":12,"version":1}],"next_cursor":""}` | | `GET /v1/im/organization/departments/{id}/members?cursor=` | Query only | `{"items":[{"user_id":"usr...","display_name":"张三","avatar_file_id":"","department_id":"dep_1","title":"工程师","can_contact":true}],"next_cursor":""}` | | `GET /v1/im/organization/members?query=&department_id=&cursor=` 待实现/可由部门成员接口替代 | Query only | `{"items":[{"user_id":"usr...","display_name":"张三","department_id":"dep_1","department_name":"研发部","can_contact":true}],"next_cursor":""}` | | `GET /v1/im/organization/search?q=&types=department,user&cursor=` | Query only | `{"items":[{"type":"user","user_id":"usr...","display_name":"张三","department_name":"研发部","can_contact":true}],"next_cursor":""}` | +| `GET /v1/im/attendance/config?date=2026-08-31` | Query only | `{"date":"2026-08-31","timezone":"Asia/Shanghai","rule":{"rule_id":"att_rule_default","name":"默认考勤规则","workday":true,"location_required":false,"wifi_required":false,"allow_outside_checkin":true,"appeal_allowed":true},"shifts":[{"shift_id":"shift_default","name":"默认班次","checkin_start":"08:30","work_start":"09:00","work_end":"18:00","checkout_end":"20:00"}],"allowed_actions":["check_in","check_out"],"today_record":{"record_id":"evt_...","user_id":"usr...","date":"2026-08-31","status":"checked_in","checkins":[]},"permissions":{"can_checkin":true,"can_appeal":true,"can_view_managed":true}}` | +| `GET /v1/im/attendance/records?scope=mine&from=2026-08-01&to=2026-08-31&cursor=` | Query only | `{"items":[{"record_id":"evt_...","user_id":"usr_me","date":"2026-08-31","rule_id":"att_rule_default","shift_id":"shift_default","status":"normal","checkins":[{"checkin_id":"attc_...","action":"check_in","client_time":"2026-08-31T00:55:00Z","server_time":"2026-08-31T00:55:02Z","result":"normal","location":{"name":"上海办公室","distance_meters":38},"wifi":{"ssid":"Office-WiFi"},"device":{"platform":"ios"}}],"exceptions":[],"appeal_status":"","version":3}],"next_cursor":"","has_more":false,"statistics":{"expected_count":1,"checked_count":1,"exception_count":0,"not_checked_count":0},"permissions":{"can_checkin":true,"can_view_managed":true,"can_appeal":true}}` | +| `GET /v1/im/attendance/records?scope=managed&department_id=dep_1&date=2026-08-31&status=late&cursor=` | Query only | `{"items":[{"record_id":"evt_...","user_id":"usr...","date":"2026-08-31","status":"late","exceptions":["late"],"appeal_status":"pending","version":2}],"next_cursor":"","has_more":false,"statistics":{"expected_count":20,"checked_count":19,"exception_count":2,"not_checked_count":1}}` | +| `POST /v1/im/attendance/checkins` | Header `Idempotency-Key`;Body `{"date":"2026-08-31","action":"check_in","rule_id":"att_rule_default","shift_id":"shift_default","client_time":"2026-08-31T08:55:00+08:00","location":{"latitude":31.2304,"longitude":121.4737,"accuracy_meters":30},"wifi":{"ssid":"Office-WiFi","bssid":"00:11:22:33:44:55"},"device":{"device_id":"ios-device-1","platform":"ios"}}` | `{"record":{"record_id":"evt_...","user_id":"usr...","date":"2026-08-31","status":"checked_in","version":2,"checkins":[{"checkin_id":"attc_...","action":"check_in","result":"normal"}]},"checkin":{"checkin_id":"attc_...","action":"check_in","client_time":"2026-08-31T00:55:00Z","server_time":"2026-08-31T00:55:02Z","result":"normal","location":{"latitude":31.2304,"longitude":121.4737},"wifi":{"ssid":"Office-WiFi"},"device":{"platform":"ios"}}}` | +| `POST /v1/im/attendance/appeals` | Header `Idempotency-Key`;Body `{"record_id":"evt_...","appeal_type":"missing_checkin","reason":"外出拜访客户,忘记打卡","target_action":"check_in","target_time":"2026-08-31T09:00:00+08:00","attachment_file_ids":["file_1"]}` | `{"appeal_id":"atta_...","record_id":"evt_...","user_id":"usr...","appeal_type":"missing_checkin","reason":"外出拜访客户,忘记打卡","target_action":"check_in","target_time":"2026-08-31T01:00:00Z","attachment_file_ids":["file_1"],"status":"pending","submitted_at":"2026-08-31T03:00:00Z","version":1}` | +| `GET /v1/im/attendance/appeals?scope=mine&status=pending&cursor=` | Query only | `{"items":[{"appeal_id":"atta_...","record_id":"evt_...","user_id":"usr...","appeal_type":"missing_checkin","status":"pending","submitted_at":"2026-08-31T03:00:00Z","version":1}],"next_cursor":"","has_more":false}` | | `GET /v1/im/meetings?cursor=` 待补列表 | Query only | `{"items":[{"meeting_id":"mtg_1","title":"周会","start_at":"2026-08-21T10:00:00Z","end_at":"2026-08-21T10:30:00Z","my_response":"pending","state":"scheduled"}],"next_cursor":""}` | | `POST /v1/im/meetings` | `{"title":"周会","start_at":"2026-08-21T10:00:00Z","end_at":"2026-08-21T10:30:00Z","location":"线上","attendee_user_ids":["usr..."],"reminder_minutes":[10]}` | `{"meeting_id":"mtg_1","title":"周会","start_at":"2026-08-21T10:00:00Z","end_at":"2026-08-21T10:30:00Z","location":"线上","participants":[{"user_id":"usr_me","response":"accepted"},{"user_id":"usr...","response":"pending"}],"permissions":{"can_update":true,"can_cancel":true,"can_remind":true,"can_respond":false},"version":1}` | | `GET /v1/im/meetings/{meeting_id}` | Path meeting ID | `{"meeting_id":"mtg_1","title":"周会","participants":[{"user_id":"usr...","response":"pending"}],"permissions":{"can_respond":true},"version":1}` | diff --git a/docs/IM-数据库变更汇总.sql b/docs/IM-数据库变更汇总.sql index ed82bfc..9fd84e7 100644 --- a/docs/IM-数据库变更汇总.sql +++ b/docs/IM-数据库变更汇总.sql @@ -818,6 +818,113 @@ CREATE TABLE IF NOT EXISTS tinode.im_feedback_submissions ( INDEX idx_im_feedback_submissions_user (user_id, id DESC) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +-- ============================================================ +-- Attendance. +-- ============================================================ + +CREATE TABLE IF NOT EXISTS tinode.im_attendance_rules ( + id BIGINT NOT NULL AUTO_INCREMENT, + rule_id VARCHAR(64) NOT NULL, + name VARCHAR(128) NOT NULL, + workday TINYINT NOT NULL DEFAULT 1, + location_required TINYINT NOT NULL DEFAULT 0, + wifi_required TINYINT NOT NULL DEFAULT 0, + allow_outside_checkin TINYINT NOT NULL DEFAULT 1, + appeal_allowed TINYINT NOT NULL DEFAULT 1, + effective_from DATE, + effective_to DATE, + state VARCHAR(16) NOT NULL DEFAULT 'active', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE INDEX uk_im_attendance_rules_rule (rule_id), + INDEX idx_im_attendance_rules_state (state, effective_from, effective_to) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS tinode.im_attendance_shifts ( + id BIGINT NOT NULL AUTO_INCREMENT, + shift_id VARCHAR(64) NOT NULL, + rule_id VARCHAR(64) NOT NULL, + name VARCHAR(128) NOT NULL, + work_start TIME NOT NULL, + work_end TIME NOT NULL, + checkin_start TIME NOT NULL, + checkout_end TIME NOT NULL, + state VARCHAR(16) NOT NULL DEFAULT 'active', + sort_order INT NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE INDEX uk_im_attendance_shifts_shift (shift_id), + INDEX idx_im_attendance_shifts_rule (rule_id, state, sort_order) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS tinode.im_attendance_records ( + id BIGINT NOT NULL AUTO_INCREMENT, + record_id VARCHAR(64) NOT NULL, + user_id BIGINT NOT NULL, + record_date DATE NOT NULL, + rule_id VARCHAR(64) NOT NULL, + shift_id VARCHAR(64) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'not_checked', + exceptions JSON, + appeal_status VARCHAR(32) NOT NULL DEFAULT '', + version INT NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE INDEX uk_im_attendance_records_record (record_id), + UNIQUE INDEX uk_im_attendance_records_user_date_shift (user_id, record_date, shift_id), + INDEX idx_im_attendance_records_user_id (user_id, id DESC), + INDEX idx_im_attendance_records_date_status (record_date, status, id DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS tinode.im_attendance_checkins ( + id BIGINT NOT NULL AUTO_INCREMENT, + checkin_id VARCHAR(64) NOT NULL, + record_id VARCHAR(64) NOT NULL, + user_id BIGINT NOT NULL, + record_date DATE NOT NULL, + action VARCHAR(32) NOT NULL, + rule_id VARCHAR(64) NOT NULL, + shift_id VARCHAR(64) NOT NULL, + client_time DATETIME(3) NOT NULL, + server_time DATETIME(3) NOT NULL, + result VARCHAR(32) NOT NULL, + location JSON, + wifi JSON, + device JSON, + idempotency_key VARCHAR(128) NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE INDEX uk_im_attendance_checkins_checkin (checkin_id), + UNIQUE INDEX uk_im_attendance_checkins_idempotency (user_id, idempotency_key), + INDEX idx_im_attendance_checkins_record (record_id, id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS tinode.im_attendance_appeals ( + id BIGINT NOT NULL AUTO_INCREMENT, + appeal_id VARCHAR(64) NOT NULL, + record_id VARCHAR(64) NOT NULL, + user_id BIGINT NOT NULL, + appeal_type VARCHAR(64) NOT NULL, + reason VARCHAR(512) NOT NULL, + target_action VARCHAR(32) NOT NULL DEFAULT '', + target_time DATETIME(3), + attachment_file_ids JSON, + status VARCHAR(32) NOT NULL DEFAULT 'pending', + version INT NOT NULL DEFAULT 1, + idempotency_key VARCHAR(128) NOT NULL, + submitted_at DATETIME(3) NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE INDEX uk_im_attendance_appeals_appeal (appeal_id), + UNIQUE INDEX uk_im_attendance_appeals_idempotency (user_id, idempotency_key), + INDEX idx_im_attendance_appeals_record (record_id), + INDEX idx_im_attendance_appeals_user_status (user_id, status, id DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + -- ============================================================ -- Seed data. -- ============================================================ @@ -837,10 +944,11 @@ ON DUPLICATE KEY UPDATE updated_at = CURRENT_TIMESTAMP(3); INSERT INTO tinode.im_workbench_apps(code, name, route, sort_order, unread_key, enabled, permission) VALUES ('organization', '组织架构', '/organization', 10, 'organization', 1, 'enabled'), - ('meetings', '会议通知', '/meetings', 20, 'meetings', 1, 'enabled'), - ('daily_reports', '日报', '/reports/daily', 30, 'daily_reports', 1, 'enabled'), - ('monthly_reports', '月报', '/reports/monthly', 40, 'monthly_reports', 1, 'enabled'), - ('executive_mailbox', '总经理信箱', '/executive-mailbox', 50, 'executive_mailbox', 1, 'enabled') + ('attendance', '打卡', '/attendance', 20, 'attendance', 1, 'enabled'), + ('meetings', '会议通知', '/meetings', 30, 'meetings', 1, 'enabled'), + ('daily_reports', '日报', '/reports/daily', 40, 'daily_reports', 1, 'enabled'), + ('monthly_reports', '月报', '/reports/monthly', 50, 'monthly_reports', 1, 'enabled'), + ('executive_mailbox', '总经理信箱', '/executive-mailbox', 60, 'executive_mailbox', 1, 'enabled') ON DUPLICATE KEY UPDATE name = VALUES(name), route = VALUES(route), @@ -848,6 +956,14 @@ ON DUPLICATE KEY UPDATE unread_key = VALUES(unread_key), updated_at = CURRENT_TIMESTAMP(3); +INSERT INTO tinode.im_attendance_rules(rule_id, name, workday, location_required, wifi_required, allow_outside_checkin, appeal_allowed) +VALUES ('att_rule_default', '默认考勤规则', 1, 0, 0, 1, 1) +ON DUPLICATE KEY UPDATE updated_at = updated_at; + +INSERT INTO tinode.im_attendance_shifts(shift_id, rule_id, name, work_start, work_end, checkin_start, checkout_end, sort_order) +VALUES ('shift_default', 'att_rule_default', '默认班次', '09:00:00', '18:00:00', '08:30:00', '20:00:00', 10) +ON DUPLICATE KEY UPDATE updated_at = updated_at; + INSERT INTO tinode.im_mailbox_config(id, enabled, allow_anonymous, required_fields, max_length, attachment_policy, notice_title, notice_content) VALUES (1, 1, 1, '["subject","content"]', 5000, '{"max_files":5,"purpose":"executive_mailbox"}', '总经理信箱', '请如实填写诉求,匿名提交将按企业政策保护展示身份。') ON DUPLICATE KEY UPDATE updated_at = updated_at; diff --git a/server/db/adapter.go b/server/db/adapter.go index 1b49616..8558d6f 100644 --- a/server/db/adapter.go +++ b/server/db/adapter.go @@ -404,6 +404,11 @@ type TenantBusinessAdapter interface { TenantOrganizationMembers(t.TenantID, t.Uid, string, string, int) ([]t.OrganizationMember, string, error) TenantOrganizationMembersAll(t.TenantID, t.Uid, string, string, string, int) ([]t.OrganizationMember, string, error) TenantOrganizationSearch(t.TenantID, t.Uid, string, string, string, int) ([]t.OrganizationSearchItem, string, error) + TenantAttendanceConfig(t.TenantID, t.Uid, time.Time) (*t.AttendanceConfig, error) + TenantAttendanceRecordList(t.TenantID, t.Uid, t.AttendanceRecordListOptions) ([]t.AttendanceRecord, string, *t.AttendanceRecordStats, error) + TenantAttendanceCheckin(t.TenantID, t.Uid, string, t.AttendanceCheckinRequest) (*t.AttendanceRecord, *t.AttendanceCheckin, error) + TenantAttendanceAppealCreate(t.TenantID, t.Uid, *t.AttendanceAppeal, string) (*t.AttendanceAppeal, error) + TenantAttendanceAppealList(t.TenantID, t.Uid, t.AttendanceAppealListOptions) ([]t.AttendanceAppeal, string, error) TenantMeetingCreate(t.TenantID, t.Uid, *t.Meeting, []t.Uid) (*t.Meeting, error) TenantMeetingList(t.TenantID, t.Uid, string, int) ([]t.Meeting, string, error) TenantMeetingGet(t.TenantID, t.Uid, string) (*t.Meeting, error) diff --git a/server/db/mysql/adapter.go b/server/db/mysql/adapter.go index 1a0ceb8..0bce20b 100644 --- a/server/db/mysql/adapter.go +++ b/server/db/mysql/adapter.go @@ -915,6 +915,9 @@ func (a *adapter) CreateDb(reset bool) error { if err = createIMOrganizationTables(tx); err != nil { return err } + if err = createIMAttendanceTables(tx); err != nil { + return err + } if err = createIMMeetingTables(tx); err != nil { return err } @@ -1142,10 +1145,11 @@ func createIMWorkbenchTables(tx *sql.Tx) error { `INSERT INTO im_workbench_apps(code,name,route,sort_order,unread_key,enabled,permission) VALUES ('organization','组织架构','/organization',10,'organization',1,'enabled'), - ('meetings','会议通知','/meetings',20,'meetings',1,'enabled'), - ('daily_reports','日报','/reports/daily',30,'daily_reports',1,'enabled'), - ('monthly_reports','月报','/reports/monthly',40,'monthly_reports',1,'enabled'), - ('executive_mailbox','总经理信箱','/executive-mailbox',50,'executive_mailbox',1,'enabled') + ('attendance','打卡','/attendance',20,'attendance',1,'enabled'), + ('meetings','会议通知','/meetings',30,'meetings',1,'enabled'), + ('daily_reports','日报','/reports/daily',40,'daily_reports',1,'enabled'), + ('monthly_reports','月报','/reports/monthly',50,'monthly_reports',1,'enabled'), + ('executive_mailbox','总经理信箱','/executive-mailbox',60,'executive_mailbox',1,'enabled') ON DUPLICATE KEY UPDATE name=VALUES(name), route=VALUES(route), @@ -1231,6 +1235,123 @@ func ensureIMOrganizationVisibilityTables(exec sqlExecutor) error { return err } +func createIMAttendanceTables(tx *sql.Tx) error { + statements := []string{ + `CREATE TABLE im_attendance_rules( + id BIGINT NOT NULL AUTO_INCREMENT, + rule_id VARCHAR(64) NOT NULL, + name VARCHAR(128) NOT NULL, + workday TINYINT NOT NULL DEFAULT 1, + location_required TINYINT NOT NULL DEFAULT 0, + wifi_required TINYINT NOT NULL DEFAULT 0, + allow_outside_checkin TINYINT NOT NULL DEFAULT 1, + appeal_allowed TINYINT NOT NULL DEFAULT 1, + effective_from DATE, + effective_to DATE, + state VARCHAR(16) NOT NULL DEFAULT 'active', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), + UNIQUE INDEX uk_im_attendance_rules_rule(rule_id), + INDEX idx_im_attendance_rules_state(state,effective_from,effective_to) + )`, + `CREATE TABLE im_attendance_shifts( + id BIGINT NOT NULL AUTO_INCREMENT, + shift_id VARCHAR(64) NOT NULL, + rule_id VARCHAR(64) NOT NULL, + name VARCHAR(128) NOT NULL, + work_start TIME NOT NULL, + work_end TIME NOT NULL, + checkin_start TIME NOT NULL, + checkout_end TIME NOT NULL, + state VARCHAR(16) NOT NULL DEFAULT 'active', + sort_order INT NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), + UNIQUE INDEX uk_im_attendance_shifts_shift(shift_id), + INDEX idx_im_attendance_shifts_rule(rule_id,state,sort_order) + )`, + `CREATE TABLE im_attendance_records( + id BIGINT NOT NULL AUTO_INCREMENT, + record_id VARCHAR(64) NOT NULL, + user_id BIGINT NOT NULL, + record_date DATE NOT NULL, + rule_id VARCHAR(64) NOT NULL, + shift_id VARCHAR(64) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'not_checked', + exceptions JSON, + appeal_status VARCHAR(32) NOT NULL DEFAULT '', + version INT NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), + UNIQUE INDEX uk_im_attendance_records_record(record_id), + UNIQUE INDEX uk_im_attendance_records_user_date_shift(user_id,record_date,shift_id), + INDEX idx_im_attendance_records_user_id(user_id,id DESC), + INDEX idx_im_attendance_records_date_status(record_date,status,id DESC) + )`, + `CREATE TABLE im_attendance_checkins( + id BIGINT NOT NULL AUTO_INCREMENT, + checkin_id VARCHAR(64) NOT NULL, + record_id VARCHAR(64) NOT NULL, + user_id BIGINT NOT NULL, + record_date DATE NOT NULL, + action VARCHAR(32) NOT NULL, + rule_id VARCHAR(64) NOT NULL, + shift_id VARCHAR(64) NOT NULL, + client_time DATETIME(3) NOT NULL, + server_time DATETIME(3) NOT NULL, + result VARCHAR(32) NOT NULL, + location JSON, + wifi JSON, + device JSON, + idempotency_key VARCHAR(128) NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), + UNIQUE INDEX uk_im_attendance_checkins_checkin(checkin_id), + UNIQUE INDEX uk_im_attendance_checkins_idempotency(user_id,idempotency_key), + INDEX idx_im_attendance_checkins_record(record_id,id) + )`, + `CREATE TABLE im_attendance_appeals( + id BIGINT NOT NULL AUTO_INCREMENT, + appeal_id VARCHAR(64) NOT NULL, + record_id VARCHAR(64) NOT NULL, + user_id BIGINT NOT NULL, + appeal_type VARCHAR(64) NOT NULL, + reason VARCHAR(512) NOT NULL, + target_action VARCHAR(32) NOT NULL DEFAULT '', + target_time DATETIME(3), + attachment_file_ids JSON, + status VARCHAR(32) NOT NULL DEFAULT 'pending', + version INT NOT NULL DEFAULT 1, + idempotency_key VARCHAR(128) NOT NULL, + submitted_at DATETIME(3) NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), + UNIQUE INDEX uk_im_attendance_appeals_appeal(appeal_id), + UNIQUE INDEX uk_im_attendance_appeals_idempotency(user_id,idempotency_key), + INDEX idx_im_attendance_appeals_record(record_id), + INDEX idx_im_attendance_appeals_user_status(user_id,status,id DESC) + )`, + } + for _, stmt := range statements { + if _, err := tx.Exec(stmt); err != nil { + return err + } + } + if _, err := tx.Exec(`INSERT INTO im_attendance_rules(rule_id,name,workday,location_required,wifi_required,allow_outside_checkin,appeal_allowed) + VALUES('att_rule_default','默认考勤规则',1,0,0,1,1) + ON DUPLICATE KEY UPDATE updated_at=updated_at`); err != nil { + return err + } + _, err := tx.Exec(`INSERT INTO im_attendance_shifts(shift_id,rule_id,name,work_start,work_end,checkin_start,checkout_end,sort_order) + VALUES('shift_default','att_rule_default','默认班次','09:00:00','18:00:00','08:30:00','20:00:00',10) + ON DUPLICATE KEY UPDATE updated_at=updated_at`) + return err +} + func createIMMeetingTables(tx *sql.Tx) error { statements := []string{ `CREATE TABLE im_meetings( diff --git a/server/db/mysql/tenant_adapter.go b/server/db/mysql/tenant_adapter.go index 54cc153..f3b7319 100644 --- a/server/db/mysql/tenant_adapter.go +++ b/server/db/mysql/tenant_adapter.go @@ -3858,6 +3858,707 @@ func uniqueMeetingParticipants(organizer t.Uid, attendeeIDs []t.Uid) []t.Uid { return participants } +func (a *adapter) TenantAttendanceConfig(id t.TenantID, uid t.Uid, date time.Time) (*t.AttendanceConfig, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, err + } + if tenantAdp != a { + return tenantAdp.TenantAttendanceConfig(id, uid, date) + } + } + if err := validTenant(id); err != nil || uid.IsZero() || date.IsZero() { + return nil, t.ErrMalformed + } + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + rule, err := a.activeAttendanceRule(ctx) + if err != nil { + return nil, err + } + shifts, err := a.attendanceShifts(ctx, rule.RuleID) + if err != nil { + return nil, err + } + record, err := a.attendanceRecordByDate(ctx, uid, date, "") + if err != nil && err != sql.ErrNoRows { + return nil, err + } + if err == sql.ErrNoRows { + record = nil + } + return &t.AttendanceConfig{ + Date: date, + Timezone: "Asia/Shanghai", + Rule: *rule, + Shifts: shifts, + AllowedActions: []string{t.AttendanceActionCheckIn, t.AttendanceActionCheckOut}, + TodayRecord: record, + CanViewManaged: true, + }, nil +} + +func (a *adapter) TenantAttendanceRecordList(id t.TenantID, uid t.Uid, opts t.AttendanceRecordListOptions) ([]t.AttendanceRecord, string, *t.AttendanceRecordStats, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, "", nil, err + } + if tenantAdp != a { + return tenantAdp.TenantAttendanceRecordList(id, uid, opts) + } + } + if err := validTenant(id); err != nil || uid.IsZero() { + return nil, "", nil, t.ErrMalformed + } + where := []string{"1=1"} + args := []any{} + if opts.Scope == "" || opts.Scope == "mine" { + where = append(where, "user_id=?") + args = append(args, decodeUID(uid)) + } else if opts.Scope == "managed" { + if raw := strings.TrimSpace(opts.UserID); raw != "" { + target := t.ParseUserId(raw) + if target.IsZero() { + return nil, "", nil, t.ErrMalformed + } + where = append(where, "user_id=?") + args = append(args, decodeUID(target)) + } + } else { + return nil, "", nil, t.ErrMalformed + } + if opts.Date != "" { + where = append(where, "record_date=?") + args = append(args, opts.Date) + } + if opts.From != "" { + where = append(where, "record_date>=?") + args = append(args, opts.From) + } + if opts.To != "" { + where = append(where, "record_date<=?") + args = append(args, opts.To) + } + if opts.Status != "" { + where = append(where, "status=?") + args = append(args, opts.Status) + } + if opts.Cursor != "" { + parsed, err := strconv.ParseInt(opts.Cursor, 10, 64) + if err != nil || parsed <= 0 { + return nil, "", nil, t.ErrMalformed + } + where = append(where, "id 100 { + limit = 50 + } + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + query := `SELECT * FROM im_attendance_records WHERE ` + strings.Join(where, " AND ") + ` ORDER BY id DESC LIMIT ?` + rows, err := a.db.QueryxContext(ctx, query, append(args, limit+1)...) + if err != nil { + return nil, "", nil, err + } + defer rows.Close() + records := make([]t.AttendanceRecord, 0, limit) + nextCursor := "" + for rows.Next() { + record, err := scanAttendanceRecord(rows) + if err != nil { + return nil, "", nil, err + } + if len(records) == limit { + nextCursor = strconv.FormatInt(record.ID, 10) + continue + } + checkins, err := a.attendanceCheckins(ctx, record.RecordID) + if err != nil { + return nil, "", nil, err + } + record.Checkins = checkins + records = append(records, *record) + } + if err = rows.Err(); err != nil { + return nil, "", nil, err + } + stats, err := a.attendanceRecordStats(ctx, opts, uid) + if err != nil { + return nil, "", nil, err + } + return records, nextCursor, stats, nil +} + +func (a *adapter) TenantAttendanceCheckin(id t.TenantID, uid t.Uid, checkinID string, req t.AttendanceCheckinRequest) (*t.AttendanceRecord, *t.AttendanceCheckin, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, nil, err + } + if tenantAdp != a { + return tenantAdp.TenantAttendanceCheckin(id, uid, checkinID, req) + } + } + if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(checkinID) == "" || strings.TrimSpace(req.IdempotencyKey) == "" { + return nil, nil, t.ErrMalformed + } + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + if existing, err := a.attendanceCheckinByIdempotency(ctx, uid, req.IdempotencyKey); err == nil { + record, recErr := a.attendanceRecordByID(ctx, existing.RecordID) + return record, existing, recErr + } else if err != sql.ErrNoRows { + return nil, nil, err + } + rule, err := a.activeAttendanceRule(ctx) + if err != nil { + return nil, nil, err + } + shifts, err := a.attendanceShifts(ctx, rule.RuleID) + if err != nil { + return nil, nil, err + } + shift := shifts[0] + if strings.TrimSpace(req.ShiftID) != "" { + for _, candidate := range shifts { + if candidate.ShiftID == strings.TrimSpace(req.ShiftID) { + shift = candidate + break + } + } + } + recordID := t.StableEventID("attendance_record", uid.UserId(), req.Date.Format("2006-01-02"), shift.ShiftID) + result := attendanceCheckinResult(req.Action, req.ClientTime, req.Date, shift) + status := attendanceRecordStatus(req.Action, result, "") + exceptions := attendanceExceptions(status) + locationJSON, _ := json.Marshal(req.Location) + wifiJSON, _ := json.Marshal(req.Wifi) + deviceJSON, _ := json.Marshal(req.Device) + exceptionsJSON, _ := json.Marshal(exceptions) + now := time.Now().UTC().Round(time.Millisecond) + tx, err := a.db.BeginTxx(ctx, nil) + if err != nil { + return nil, nil, err + } + defer func() { + if tx != nil { + tx.Rollback() + } + }() + if _, err = tx.ExecContext(ctx, `INSERT INTO im_attendance_records(record_id,user_id,record_date,rule_id,shift_id,status,exceptions,appeal_status,version) + VALUES(?,?,?,?,?,?,?,?,1) + ON DUPLICATE KEY UPDATE record_id=record_id`, recordID, decodeUID(uid), req.Date.Format("2006-01-02"), rule.RuleID, shift.ShiftID, + t.AttendanceStatusNotChecked, "[]", ""); err != nil { + return nil, nil, err + } + current := struct { + RecordID string `db:"record_id"` + Status string + Version int + }{} + if err = sqlx.GetContext(ctx, tx, ¤t, `SELECT record_id,status,version FROM im_attendance_records WHERE user_id=? AND record_date=? AND shift_id=?`, + decodeUID(uid), req.Date.Format("2006-01-02"), shift.ShiftID); err != nil { + return nil, nil, err + } + status = attendanceRecordStatus(req.Action, result, current.Status) + exceptionsJSON, _ = json.Marshal(attendanceExceptions(status)) + if _, err = tx.ExecContext(ctx, `INSERT INTO im_attendance_checkins(checkin_id,record_id,user_id,record_date,action,rule_id,shift_id,client_time,server_time,result,location,wifi,device,idempotency_key) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, checkinID, current.RecordID, decodeUID(uid), req.Date.Format("2006-01-02"), req.Action, rule.RuleID, shift.ShiftID, + req.ClientTime.UTC().Round(time.Millisecond), now, result, string(locationJSON), string(wifiJSON), string(deviceJSON), req.IdempotencyKey); err != nil { + if isDupe(err) { + tx.Rollback() + tx = nil + existing, lookupErr := a.attendanceCheckinByIdempotency(ctx, uid, req.IdempotencyKey) + if lookupErr != nil { + return nil, nil, lookupErr + } + record, recErr := a.attendanceRecordByID(ctx, existing.RecordID) + return record, existing, recErr + } + return nil, nil, err + } + if _, err = tx.ExecContext(ctx, `UPDATE im_attendance_records SET status=?,exceptions=?,version=version+1,updated_at=CURRENT_TIMESTAMP(3) + WHERE record_id=?`, status, string(exceptionsJSON), current.RecordID); err != nil { + return nil, nil, err + } + if err = tx.Commit(); err != nil { + return nil, nil, err + } + tx = nil + checkin, err := a.attendanceCheckinByIdempotency(ctx, uid, req.IdempotencyKey) + if err != nil { + return nil, nil, err + } + record, err := a.attendanceRecordByID(ctx, checkin.RecordID) + return record, checkin, err +} + +func (a *adapter) TenantAttendanceAppealCreate(id t.TenantID, uid t.Uid, appeal *t.AttendanceAppeal, idempotencyKey string) (*t.AttendanceAppeal, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, err + } + if tenantAdp != a { + return tenantAdp.TenantAttendanceAppealCreate(id, uid, appeal, idempotencyKey) + } + } + if err := validTenant(id); err != nil || uid.IsZero() || appeal == nil || strings.TrimSpace(idempotencyKey) == "" { + return nil, t.ErrMalformed + } + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + if existing, err := a.attendanceAppealByIdempotency(ctx, uid, idempotencyKey); err == nil { + return existing, nil + } else if err != sql.ErrNoRows { + return nil, err + } + record, err := a.attendanceRecordByID(ctx, strings.TrimSpace(appeal.RecordID)) + if err != nil { + if err == sql.ErrNoRows { + return nil, t.ErrNotFound + } + return nil, err + } + if record.UserID != uid { + return nil, t.ErrPermissionDenied + } + attachmentsJSON, _ := json.Marshal(appeal.AttachmentFileIDs) + now := time.Now().UTC().Round(time.Millisecond) + if appeal.TargetTime != nil { + tm := appeal.TargetTime.UTC().Round(time.Millisecond) + appeal.TargetTime = &tm + } + _, err := a.db.ExecContext(ctx, `INSERT INTO im_attendance_appeals(appeal_id,record_id,user_id,appeal_type,reason,target_action,target_time,attachment_file_ids,status,version,idempotency_key,submitted_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`, strings.TrimSpace(appeal.AppealID), strings.TrimSpace(appeal.RecordID), decodeUID(uid), strings.TrimSpace(appeal.AppealType), + strings.TrimSpace(appeal.Reason), strings.TrimSpace(appeal.TargetAction), appeal.TargetTime, string(attachmentsJSON), t.AttendanceAppealPending, 1, + idempotencyKey, now) + if err != nil { + if isDupe(err) { + return a.attendanceAppealByIdempotency(ctx, uid, idempotencyKey) + } + return nil, err + } + _, _ = a.db.ExecContext(ctx, `UPDATE im_attendance_records SET appeal_status=?,updated_at=CURRENT_TIMESTAMP(3) WHERE record_id=?`, + t.AttendanceAppealPending, strings.TrimSpace(appeal.RecordID)) + return a.attendanceAppealByIdempotency(ctx, uid, idempotencyKey) +} + +func (a *adapter) TenantAttendanceAppealList(id t.TenantID, uid t.Uid, opts t.AttendanceAppealListOptions) ([]t.AttendanceAppeal, string, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, "", err + } + if tenantAdp != a { + return tenantAdp.TenantAttendanceAppealList(id, uid, opts) + } + } + if err := validTenant(id); err != nil || uid.IsZero() { + return nil, "", t.ErrMalformed + } + where := []string{"1=1"} + args := []any{} + if opts.Scope == "" || opts.Scope == "mine" { + where = append(where, "user_id=?") + args = append(args, decodeUID(uid)) + } else if opts.Scope != "managed" { + return nil, "", t.ErrMalformed + } + if opts.Status != "" { + where = append(where, "status=?") + args = append(args, opts.Status) + } + if opts.Cursor != "" { + parsed, err := strconv.ParseInt(opts.Cursor, 10, 64) + if err != nil || parsed <= 0 { + return nil, "", t.ErrMalformed + } + where = append(where, "id 100 { + limit = 50 + } + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + rows, err := a.db.QueryxContext(ctx, `SELECT * FROM im_attendance_appeals WHERE `+strings.Join(where, " AND ")+` ORDER BY id DESC LIMIT ?`, append(args, limit+1)...) + if err != nil { + return nil, "", err + } + defer rows.Close() + appeals := make([]t.AttendanceAppeal, 0, limit) + nextCursor := "" + for rows.Next() { + appeal, err := scanAttendanceAppeal(rows) + if err != nil { + return nil, "", err + } + if len(appeals) == limit { + nextCursor = strconv.FormatInt(appeal.ID, 10) + continue + } + appeals = append(appeals, *appeal) + } + if err = rows.Err(); err != nil { + return nil, "", err + } + return appeals, nextCursor, nil +} + +func (a *adapter) activeAttendanceRule(ctx context.Context) (*t.AttendanceRule, error) { + row := struct { + ID int64 + RuleID string `db:"rule_id"` + Name string + Workday bool + LocationRequired bool `db:"location_required"` + WifiRequired bool `db:"wifi_required"` + AllowOutsideCheckin bool `db:"allow_outside_checkin"` + AppealAllowed bool `db:"appeal_allowed"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + }{} + if err := sqlx.GetContext(ctx, a.db, &row, `SELECT id,rule_id,name,workday,location_required,wifi_required,allow_outside_checkin,appeal_allowed,created_at,updated_at + FROM im_attendance_rules WHERE state='active' ORDER BY id ASC LIMIT 1`); err != nil { + if err == sql.ErrNoRows { + now := time.Now().UTC().Round(time.Millisecond) + return &t.AttendanceRule{RuleID: "att_rule_default", Name: "默认考勤规则", Workday: true, AllowOutsideCheckin: true, AppealAllowed: true, CreatedAt: now, UpdatedAt: now}, nil + } + return nil, err + } + return &t.AttendanceRule{ + ID: row.ID, RuleID: row.RuleID, Name: row.Name, Workday: row.Workday, LocationRequired: row.LocationRequired, + WifiRequired: row.WifiRequired, AllowOutsideCheckin: row.AllowOutsideCheckin, AppealAllowed: row.AppealAllowed, + CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, + }, nil +} + +func (a *adapter) attendanceShifts(ctx context.Context, ruleID string) ([]t.AttendanceShift, error) { + rows, err := a.db.QueryxContext(ctx, `SELECT id,shift_id,rule_id,name,TIME_FORMAT(work_start,'%H:%i') AS work_start,TIME_FORMAT(work_end,'%H:%i') AS work_end, + TIME_FORMAT(checkin_start,'%H:%i') AS checkin_start,TIME_FORMAT(checkout_end,'%H:%i') AS checkout_end,sort_order,created_at,updated_at + FROM im_attendance_shifts WHERE rule_id=? AND state='active' ORDER BY sort_order ASC,id ASC`, ruleID) + if err != nil { + return nil, err + } + defer rows.Close() + shifts := make([]t.AttendanceShift, 0, 2) + for rows.Next() { + var shift t.AttendanceShift + if err = rows.StructScan(&shift); err != nil { + return nil, err + } + shifts = append(shifts, shift) + } + if err = rows.Err(); err != nil { + return nil, err + } + if len(shifts) == 0 { + now := time.Now().UTC().Round(time.Millisecond) + shifts = append(shifts, t.AttendanceShift{ShiftID: "shift_default", RuleID: ruleID, Name: "默认班次", WorkStart: "09:00", WorkEnd: "18:00", CheckinStart: "08:30", CheckoutEnd: "20:00", SortOrder: 10, CreatedAt: now, UpdatedAt: now}) + } + return shifts, nil +} + +func (a *adapter) attendanceRecordByDate(ctx context.Context, uid t.Uid, date time.Time, shiftID string) (*t.AttendanceRecord, error) { + query := `SELECT * FROM im_attendance_records WHERE user_id=? AND record_date=?` + args := []any{decodeUID(uid), date.Format("2006-01-02")} + if shiftID != "" { + query += ` AND shift_id=?` + args = append(args, shiftID) + } + query += ` ORDER BY id ASC LIMIT 1` + rows, err := a.db.QueryxContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + if rows.Next() { + record, err := scanAttendanceRecord(rows) + if err != nil { + return nil, err + } + record.Checkins, err = a.attendanceCheckins(ctx, record.RecordID) + return record, err + } + if err = rows.Err(); err != nil { + return nil, err + } + return nil, sql.ErrNoRows +} + +func (a *adapter) attendanceRecordByID(ctx context.Context, recordID string) (*t.AttendanceRecord, error) { + rows, err := a.db.QueryxContext(ctx, `SELECT * FROM im_attendance_records WHERE record_id=?`, recordID) + if err != nil { + return nil, err + } + defer rows.Close() + if rows.Next() { + record, err := scanAttendanceRecord(rows) + if err != nil { + return nil, err + } + record.Checkins, err = a.attendanceCheckins(ctx, record.RecordID) + return record, err + } + if err = rows.Err(); err != nil { + return nil, err + } + return nil, sql.ErrNoRows +} + +func (a *adapter) attendanceCheckins(ctx context.Context, recordID string) ([]t.AttendanceCheckin, error) { + rows, err := a.db.QueryxContext(ctx, `SELECT * FROM im_attendance_checkins WHERE record_id=? ORDER BY id ASC`, recordID) + if err != nil { + return nil, err + } + defer rows.Close() + items := make([]t.AttendanceCheckin, 0, 2) + for rows.Next() { + item, err := scanAttendanceCheckin(rows) + if err != nil { + return nil, err + } + items = append(items, *item) + } + return items, rows.Err() +} + +func (a *adapter) attendanceCheckinByIdempotency(ctx context.Context, uid t.Uid, key string) (*t.AttendanceCheckin, error) { + rows, err := a.db.QueryxContext(ctx, `SELECT * FROM im_attendance_checkins WHERE user_id=? AND idempotency_key=?`, decodeUID(uid), key) + if err != nil { + return nil, err + } + defer rows.Close() + if rows.Next() { + return scanAttendanceCheckin(rows) + } + if err = rows.Err(); err != nil { + return nil, err + } + return nil, sql.ErrNoRows +} + +func (a *adapter) attendanceAppealByIdempotency(ctx context.Context, uid t.Uid, key string) (*t.AttendanceAppeal, error) { + rows, err := a.db.QueryxContext(ctx, `SELECT * FROM im_attendance_appeals WHERE user_id=? AND idempotency_key=?`, decodeUID(uid), key) + if err != nil { + return nil, err + } + defer rows.Close() + if rows.Next() { + return scanAttendanceAppeal(rows) + } + if err = rows.Err(); err != nil { + return nil, err + } + return nil, sql.ErrNoRows +} + +func (a *adapter) attendanceRecordStats(ctx context.Context, opts t.AttendanceRecordListOptions, viewer t.Uid) (*t.AttendanceRecordStats, error) { + where := []string{"1=1"} + args := []any{} + if opts.Scope == "" || opts.Scope == "mine" { + where = append(where, "user_id=?") + args = append(args, decodeUID(viewer)) + } + if opts.Date != "" { + where = append(where, "record_date=?") + args = append(args, opts.Date) + } + if opts.From != "" { + where = append(where, "record_date>=?") + args = append(args, opts.From) + } + if opts.To != "" { + where = append(where, "record_date<=?") + args = append(args, opts.To) + } + stats := &t.AttendanceRecordStats{} + if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM im_attendance_records WHERE `+strings.Join(where, " AND "), args...).Scan(&stats.CheckedCount); err != nil { + return nil, err + } + exceptionWhere := append([]string{}, where...) + exceptionWhere = append(exceptionWhere, "status IN (?,?,?)") + exceptionArgs := append(append([]any{}, args...), t.AttendanceStatusLate, t.AttendanceStatusEarlyLeave, t.AttendanceStatusAbnormal) + if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM im_attendance_records WHERE `+strings.Join(exceptionWhere, " AND "), exceptionArgs...).Scan(&stats.ExceptionCount); err != nil { + return nil, err + } + if err := a.db.QueryRowContext(ctx, `SELECT COUNT(DISTINCT user_id) FROM im_org_members WHERE employment_state='active'`).Scan(&stats.ExpectedCount); err != nil { + stats.ExpectedCount = stats.CheckedCount + } + if opts.Scope == "" || opts.Scope == "mine" { + stats.ExpectedCount = 1 + } + stats.NotCheckedCount = stats.ExpectedCount - stats.CheckedCount + if stats.NotCheckedCount < 0 { + stats.NotCheckedCount = 0 + } + return stats, nil +} + +func scanAttendanceRecord(rows *sqlx.Rows) (*t.AttendanceRecord, error) { + row := struct { + ID int64 + RecordID string `db:"record_id"` + UserID int64 `db:"user_id"` + Date time.Time `db:"record_date"` + RuleID string `db:"rule_id"` + ShiftID string `db:"shift_id"` + Status string + Exceptions []byte + AppealStatus string `db:"appeal_status"` + Version int + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + }{} + if err := rows.StructScan(&row); err != nil { + return nil, err + } + record := &t.AttendanceRecord{ + ID: row.ID, RecordID: row.RecordID, UserID: store.EncodeUid(row.UserID), Date: row.Date, + RuleID: row.RuleID, ShiftID: row.ShiftID, Status: row.Status, AppealStatus: row.AppealStatus, + Version: row.Version, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, + } + if len(row.Exceptions) > 0 { + _ = json.Unmarshal(row.Exceptions, &record.Exceptions) + } + return record, nil +} + +func scanAttendanceCheckin(rows *sqlx.Rows) (*t.AttendanceCheckin, error) { + row := struct { + ID int64 + CheckinID string `db:"checkin_id"` + RecordID string `db:"record_id"` + UserID int64 `db:"user_id"` + Date time.Time `db:"record_date"` + Action string + RuleID string `db:"rule_id"` + ShiftID string `db:"shift_id"` + ClientTime time.Time `db:"client_time"` + ServerTime time.Time `db:"server_time"` + Result string + Location []byte + Wifi []byte + Device []byte + IdempotencyKey string `db:"idempotency_key"` + CreatedAt time.Time `db:"created_at"` + }{} + if err := rows.StructScan(&row); err != nil { + return nil, err + } + item := &t.AttendanceCheckin{ + ID: row.ID, CheckinID: row.CheckinID, RecordID: row.RecordID, UserID: store.EncodeUid(row.UserID), Date: row.Date, + Action: row.Action, RuleID: row.RuleID, ShiftID: row.ShiftID, ClientTime: row.ClientTime, ServerTime: row.ServerTime, + Result: row.Result, IdempotencyKey: row.IdempotencyKey, CreatedAt: row.CreatedAt, + } + _ = json.Unmarshal(row.Location, &item.Location) + _ = json.Unmarshal(row.Wifi, &item.Wifi) + _ = json.Unmarshal(row.Device, &item.Device) + return item, nil +} + +func scanAttendanceAppeal(rows *sqlx.Rows) (*t.AttendanceAppeal, error) { + row := struct { + ID int64 + AppealID string `db:"appeal_id"` + RecordID string `db:"record_id"` + UserID int64 `db:"user_id"` + AppealType string `db:"appeal_type"` + Reason string + TargetAction string `db:"target_action"` + TargetTime *time.Time `db:"target_time"` + AttachmentFileIDs []byte `db:"attachment_file_ids"` + Status string + Version int + SubmittedAt time.Time `db:"submitted_at"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + }{} + if err := rows.StructScan(&row); err != nil { + return nil, err + } + appeal := &t.AttendanceAppeal{ + ID: row.ID, AppealID: row.AppealID, RecordID: row.RecordID, UserID: store.EncodeUid(row.UserID), + AppealType: row.AppealType, Reason: row.Reason, TargetAction: row.TargetAction, TargetTime: row.TargetTime, + Status: row.Status, Version: row.Version, SubmittedAt: row.SubmittedAt, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, + } + if len(row.AttachmentFileIDs) > 0 { + _ = json.Unmarshal(row.AttachmentFileIDs, &appeal.AttachmentFileIDs) + } + return appeal, nil +} + +func attendanceCheckinResult(action string, clientTime, date time.Time, shift t.AttendanceShift) string { + switch action { + case t.AttendanceActionCheckIn: + if clientTime.After(attendanceTimeOnDate(date, shift.WorkStart)) { + return t.AttendanceCheckinLate + } + case t.AttendanceActionCheckOut: + if clientTime.Before(attendanceTimeOnDate(date, shift.WorkEnd)) { + return t.AttendanceCheckinEarlyLeave + } + } + return t.AttendanceCheckinNormal +} + +func attendanceRecordStatus(action, result, current string) string { + if result == t.AttendanceCheckinLate { + return t.AttendanceStatusLate + } + if result == t.AttendanceCheckinEarlyLeave { + return t.AttendanceStatusEarlyLeave + } + if current == t.AttendanceStatusLate || current == t.AttendanceStatusEarlyLeave || current == t.AttendanceStatusAbnormal { + return current + } + if action == t.AttendanceActionCheckOut { + return t.AttendanceStatusNormal + } + return t.AttendanceStatusCheckedIn +} + +func attendanceExceptions(status string) []string { + switch status { + case t.AttendanceStatusLate: + return []string{"late"} + case t.AttendanceStatusEarlyLeave: + return []string{"early_leave"} + case t.AttendanceStatusAbnormal: + return []string{"abnormal"} + default: + return []string{} + } +} + +func attendanceTimeOnDate(date time.Time, clock string) time.Time { + parts := strings.Split(clock, ":") + hour, minute := 0, 0 + if len(parts) >= 2 { + hour, _ = strconv.Atoi(parts[0]) + minute, _ = strconv.Atoi(parts[1]) + } + loc := time.FixedZone("CST", 8*3600) + localDate := date.In(loc) + return time.Date(localDate.Year(), localDate.Month(), localDate.Day(), hour, minute, 0, 0, loc).UTC() +} + func (a *adapter) TenantDailyReportList(id t.TenantID, uid t.Uid, scope, from, to, status, cursor string, limit int) ([]t.DailyReport, string, error) { if !a.tenantScopedDB { tenantAdp, err := a.routedTenantAdapter(id) @@ -5393,7 +6094,7 @@ func (a *adapter) userPrivacyByUID(ctx context.Context, uid t.Uid) (*t.UserPriva func isAllowedWorkbenchRoute(route string) bool { switch route { - case "/organization", "/meetings", "/reports/daily", "/reports/monthly", "/executive-mailbox": + case "/organization", "/attendance", "/meetings", "/reports/daily", "/reports/monthly", "/executive-mailbox": return true default: return false diff --git a/server/im_http.go b/server/im_http.go index b4f29a1..07189eb 100644 --- a/server/im_http.go +++ b/server/im_http.go @@ -367,6 +367,26 @@ type imFeedbackRequest struct { IncludeDiagnostics bool `json:"include_diagnostics,omitempty"` } +type imAttendanceCheckinRequest struct { + Date string `json:"date"` + Action string `json:"action"` + RuleID string `json:"rule_id,omitempty"` + ShiftID string `json:"shift_id,omitempty"` + ClientTime string `json:"client_time"` + Location types.KVMap `json:"location,omitempty"` + Wifi types.KVMap `json:"wifi,omitempty"` + Device types.KVMap `json:"device,omitempty"` +} + +type imAttendanceAppealRequest struct { + RecordID string `json:"record_id"` + AppealType string `json:"appeal_type"` + Reason string `json:"reason"` + TargetAction string `json:"target_action,omitempty"` + TargetTime string `json:"target_time,omitempty"` + AttachmentFileIDs []string `json:"attachment_file_ids,omitempty"` +} + type imSystemNotificationReadRequest struct { ReadAt string `json:"read_at,omitempty"` } @@ -1661,6 +1681,7 @@ func serveIMBootstrap(wrt http.ResponseWriter, req *http.Request) { "user_search": true, "scan_qr": true, "system_notifications": true, + "attendance": true, "message_recall": true, "message_edit": true, }, @@ -1724,6 +1745,7 @@ func serveIMWorkbench(wrt http.ResponseWriter, req *http.Request) { "tenant_code": tenant.Code, "feature_flags": map[string]any{ "organization": true, + "attendance": true, "meetings": true, "daily_reports": true, "monthly_reports": true, @@ -1842,6 +1864,192 @@ func serveIMOrganizationSearch(wrt http.ResponseWriter, req *http.Request) { }) } +func serveIMAttendanceConfig(wrt http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + date, err := parseIMOptionalDate(req.URL.Query().Get("date"), time.Now().UTC()) + if err != nil { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "date"}) + return + } + config, err := store.Attendance.Config(principal.TenantID, principal.Uid, date) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, attendanceConfigPayload(config)) +} + +func serveIMAttendanceRecords(wrt http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + if !validIMOptionalDateQuery(wrt, req, "date") || !validIMOptionalDateQuery(wrt, req, "from") || !validIMOptionalDateQuery(wrt, req, "to") { + return + } + limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100) + records, nextCursor, stats, err := store.Attendance.ListRecords(principal.TenantID, principal.Uid, types.AttendanceRecordListOptions{ + Scope: req.URL.Query().Get("scope"), + From: strings.TrimSpace(req.URL.Query().Get("from")), + To: strings.TrimSpace(req.URL.Query().Get("to")), + Date: strings.TrimSpace(req.URL.Query().Get("date")), + Status: strings.TrimSpace(req.URL.Query().Get("status")), + DepartmentID: strings.TrimSpace(req.URL.Query().Get("department_id")), + UserID: strings.TrimSpace(req.URL.Query().Get("user_id")), + Cursor: strings.TrimSpace(req.URL.Query().Get("cursor")), + Limit: limit, + }) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, map[string]any{ + "items": attendanceRecordPayloads(records), + "next_cursor": nextCursor, + "has_more": nextCursor != "", + "statistics": attendanceStatsPayload(stats), + "permissions": map[string]any{ + "can_checkin": true, + "can_view_managed": true, + "can_appeal": true, + }, + }) +} + +func serveIMAttendanceCheckins(wrt http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + idempotencyKey, ok := requireIMIdempotencyKey(wrt, req) + if !ok { + return + } + var body imAttendanceCheckinRequest + if !decodeIMJSONBody(wrt, req, &body) { + return + } + date, err := parseIMDate(body.Date) + if err != nil { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "date"}) + return + } + clientTime, err := parseIMTime(body.ClientTime) + if err != nil { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "client_time"}) + return + } + token, err := randomIMToken(12) + if err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + record, checkin, err := store.Attendance.Checkin(principal.TenantID, principal.Uid, "attc_"+token, types.AttendanceCheckinRequest{ + Date: date, + Action: strings.TrimSpace(body.Action), + RuleID: strings.TrimSpace(body.RuleID), + ShiftID: strings.TrimSpace(body.ShiftID), + ClientTime: clientTime, + Location: body.Location, + Wifi: body.Wifi, + Device: body.Device, + IdempotencyKey: idempotencyKey, + }) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "attendance", "attendance.record.updated", record.RecordID, + []types.Uid{principal.Uid}, "打卡", record.Date.Format("2006-01-02"), map[string]any{"record_id": record.RecordID, "checkin_id": checkin.CheckinID, "status": record.Status}) + writeIMSuccess(wrt, req, map[string]any{ + "record": attendanceRecordPayload(record), + "checkin": attendanceCheckinPayload(*checkin), + }) +} + +func serveIMAttendanceAppeals(wrt http.ResponseWriter, req *http.Request) { + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + switch req.Method { + case http.MethodGet: + limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100) + items, nextCursor, err := store.Attendance.ListAppeals(principal.TenantID, principal.Uid, types.AttendanceAppealListOptions{ + Scope: req.URL.Query().Get("scope"), + Status: strings.TrimSpace(req.URL.Query().Get("status")), + Cursor: strings.TrimSpace(req.URL.Query().Get("cursor")), + Limit: limit, + }) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, map[string]any{"items": attendanceAppealPayloads(items), "next_cursor": nextCursor, "has_more": nextCursor != ""}) + case http.MethodPost: + idempotencyKey, ok := requireIMIdempotencyKey(wrt, req) + if !ok { + return + } + var body imAttendanceAppealRequest + if !decodeIMJSONBody(wrt, req, &body) { + return + } + if strings.TrimSpace(body.Reason) == "" || len([]rune(body.Reason)) > 500 { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "reason"}) + return + } + var targetTime *time.Time + if strings.TrimSpace(body.TargetTime) != "" { + parsed, err := parseIMTime(body.TargetTime) + if err != nil { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "target_time"}) + return + } + targetTime = &parsed + } + token, err := randomIMToken(12) + if err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + appeal, err := store.Attendance.CreateAppeal(principal.TenantID, principal.Uid, &types.AttendanceAppeal{ + AppealID: "atta_" + token, + RecordID: strings.TrimSpace(body.RecordID), + UserID: principal.Uid, + AppealType: strings.TrimSpace(body.AppealType), + Reason: strings.TrimSpace(body.Reason), + TargetAction: strings.TrimSpace(body.TargetAction), + TargetTime: targetTime, + AttachmentFileIDs: body.AttachmentFileIDs, + }, idempotencyKey) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "attendance", "attendance.appeal.created", appeal.AppealID, + []types.Uid{principal.Uid}, "考勤申诉", appeal.RecordID, map[string]any{"appeal_id": appeal.AppealID, "record_id": appeal.RecordID, "status": appeal.Status}) + writeIMSuccess(wrt, req, attendanceAppealPayload(appeal)) + default: + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + } +} + func serveIMMeetings(wrt http.ResponseWriter, req *http.Request) { if req.Method == http.MethodGet { serveIMMeetingList(wrt, req) @@ -6215,6 +6423,25 @@ func parseIMDate(raw string) (time.Time, error) { return parsed, nil } +func parseIMOptionalDate(raw string, fallback time.Time) (time.Time, error) { + if strings.TrimSpace(raw) == "" { + return time.Date(fallback.UTC().Year(), fallback.UTC().Month(), fallback.UTC().Day(), 0, 0, 0, 0, time.UTC), nil + } + return parseIMDate(raw) +} + +func validIMOptionalDateQuery(wrt http.ResponseWriter, req *http.Request, field string) bool { + value := strings.TrimSpace(req.URL.Query().Get(field)) + if value == "" { + return true + } + if _, err := parseIMDate(value); err != nil { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": field}) + return false + } + return true +} + func validIMMonth(raw string) bool { if _, err := time.ParseInLocation("2006-01", strings.TrimSpace(raw), time.UTC); err != nil { return false @@ -7273,6 +7500,146 @@ func organizationSearchPayloads(items []types.OrganizationSearchItem) []map[stri return payload } +func attendanceConfigPayload(config *types.AttendanceConfig) map[string]any { + return map[string]any{ + "date": config.Date.Format("2006-01-02"), + "timezone": config.Timezone, + "rule": attendanceRulePayload(config.Rule), + "shifts": attendanceShiftPayloads(config.Shifts), + "allowed_actions": config.AllowedActions, + "today_record": attendanceRecordPayload(config.TodayRecord), + "permissions": map[string]any{ + "can_checkin": true, + "can_view_managed": config.CanViewManaged, + "can_appeal": config.Rule.AppealAllowed, + }, + } +} + +func attendanceRulePayload(rule types.AttendanceRule) map[string]any { + data := map[string]any{ + "rule_id": rule.RuleID, + "name": rule.Name, + "workday": rule.Workday, + "location_required": rule.LocationRequired, + "wifi_required": rule.WifiRequired, + "allow_outside_checkin": rule.AllowOutsideCheckin, + "appeal_allowed": rule.AppealAllowed, + } + if rule.AppealDeadline != nil { + data["appeal_deadline"] = rule.AppealDeadline.UTC().Format(time.RFC3339Nano) + } + return data +} + +func attendanceShiftPayloads(shifts []types.AttendanceShift) []map[string]any { + items := make([]map[string]any, 0, len(shifts)) + for _, shift := range shifts { + items = append(items, map[string]any{ + "shift_id": shift.ShiftID, + "name": shift.Name, + "work_start": shift.WorkStart, + "work_end": shift.WorkEnd, + "checkin_start": shift.CheckinStart, + "checkout_end": shift.CheckoutEnd, + }) + } + return items +} + +func attendanceRecordPayloads(records []types.AttendanceRecord) []map[string]any { + items := make([]map[string]any, 0, len(records)) + for i := range records { + items = append(items, attendanceRecordPayload(&records[i])) + } + return items +} + +func attendanceRecordPayload(record *types.AttendanceRecord) map[string]any { + if record == nil { + return nil + } + return map[string]any{ + "record_id": record.RecordID, + "user_id": record.UserID.UserId(), + "date": record.Date.Format("2006-01-02"), + "rule_id": record.RuleID, + "shift_id": record.ShiftID, + "status": record.Status, + "checkins": attendanceCheckinPayloads(record.Checkins), + "exceptions": record.Exceptions, + "appeal_status": record.AppealStatus, + "version": record.Version, + "created_at": record.CreatedAt.UTC().Format(time.RFC3339Nano), + "updated_at": record.UpdatedAt.UTC().Format(time.RFC3339Nano), + } +} + +func attendanceCheckinPayloads(checkins []types.AttendanceCheckin) []map[string]any { + items := make([]map[string]any, 0, len(checkins)) + for _, checkin := range checkins { + items = append(items, attendanceCheckinPayload(checkin)) + } + return items +} + +func attendanceCheckinPayload(checkin types.AttendanceCheckin) map[string]any { + return map[string]any{ + "checkin_id": checkin.CheckinID, + "action": checkin.Action, + "client_time": checkin.ClientTime.UTC().Format(time.RFC3339Nano), + "server_time": checkin.ServerTime.UTC().Format(time.RFC3339Nano), + "result": checkin.Result, + "location": checkin.Location, + "wifi": checkin.Wifi, + "device": checkin.Device, + } +} + +func attendanceStatsPayload(stats *types.AttendanceRecordStats) map[string]any { + if stats == nil { + return map[string]any{"expected_count": 0, "checked_count": 0, "exception_count": 0, "not_checked_count": 0} + } + return map[string]any{ + "expected_count": stats.ExpectedCount, + "checked_count": stats.CheckedCount, + "exception_count": stats.ExceptionCount, + "not_checked_count": stats.NotCheckedCount, + } +} + +func attendanceAppealPayloads(appeals []types.AttendanceAppeal) []map[string]any { + items := make([]map[string]any, 0, len(appeals)) + for i := range appeals { + items = append(items, attendanceAppealPayload(&appeals[i])) + } + return items +} + +func attendanceAppealPayload(appeal *types.AttendanceAppeal) map[string]any { + if appeal == nil { + return nil + } + data := map[string]any{ + "appeal_id": appeal.AppealID, + "record_id": appeal.RecordID, + "user_id": appeal.UserID.UserId(), + "appeal_type": appeal.AppealType, + "reason": appeal.Reason, + "target_action": appeal.TargetAction, + "attachment_file_ids": appeal.AttachmentFileIDs, + "status": appeal.Status, + "version": appeal.Version, + "submitted_at": appeal.SubmittedAt.UTC().Format(time.RFC3339Nano), + "created_at": appeal.CreatedAt.UTC().Format(time.RFC3339Nano), + "updated_at": appeal.UpdatedAt.UTC().Format(time.RFC3339Nano), + } + if appeal.TargetTime != nil { + data["target_time"] = appeal.TargetTime.UTC().Format(time.RFC3339Nano) + } + return data +} + func meetingPayload(meeting *types.Meeting, viewer types.Uid) map[string]any { return meetingPayloadWithUserSummaries(meeting, viewer, nil) } @@ -7835,7 +8202,7 @@ func tenantEntryFeedbackPayload(feedback *types.TenantEntryFeedback) map[string] func isAllowedIMWorkbenchRoute(route string) bool { switch route { - case "/organization", "/meetings", "/reports/daily", "/reports/monthly", "/executive-mailbox": + case "/organization", "/attendance", "/meetings", "/reports/daily", "/reports/monthly", "/executive-mailbox": return true default: return false diff --git a/server/im_http_test.go b/server/im_http_test.go index 4b92029..8d16263 100644 --- a/server/im_http_test.go +++ b/server/im_http_test.go @@ -279,6 +279,14 @@ type testDailyReportsStore struct { remindKeys string } +type testAttendanceStore struct { + config *types.AttendanceConfig + records []types.AttendanceRecord + checkinRequest types.AttendanceCheckinRequest + appeal *types.AttendanceAppeal + idempotencyKey string +} + type testMonthlyReportsStore struct { report *types.MonthlyReport saved bool @@ -695,6 +703,78 @@ func (s *testDailyReportsStore) Remind(_ types.TenantID, _ types.Uid, _ time.Tim return userIDs, nil } +func (s *testAttendanceStore) Config(_ types.TenantID, uid types.Uid, date time.Time) (*types.AttendanceConfig, error) { + if s.config != nil { + return s.config, nil + } + now := time.Now().UTC().Round(time.Millisecond) + return &types.AttendanceConfig{ + Date: date, + Timezone: "Asia/Shanghai", + Rule: types.AttendanceRule{ + RuleID: "att_rule_default", + Name: "默认考勤规则", + Workday: true, + AllowOutsideCheckin: true, + AppealAllowed: true, + CreatedAt: now, + UpdatedAt: now, + }, + Shifts: []types.AttendanceShift{{ + ShiftID: "shift_default", RuleID: "att_rule_default", Name: "默认班次", + WorkStart: "09:00", WorkEnd: "18:00", CheckinStart: "08:30", CheckoutEnd: "20:00", + CreatedAt: now, UpdatedAt: now, + }}, + AllowedActions: []string{types.AttendanceActionCheckIn, types.AttendanceActionCheckOut}, + TodayRecord: &types.AttendanceRecord{ + RecordID: "evt_record", UserID: uid, Date: date, RuleID: "att_rule_default", ShiftID: "shift_default", + Status: types.AttendanceStatusNotChecked, Version: 1, CreatedAt: now, UpdatedAt: now, + }, + CanViewManaged: true, + }, nil +} + +func (s *testAttendanceStore) ListRecords(_ types.TenantID, _ types.Uid, opts types.AttendanceRecordListOptions) ([]types.AttendanceRecord, string, *types.AttendanceRecordStats, error) { + return s.records, opts.Cursor, &types.AttendanceRecordStats{ExpectedCount: 1, CheckedCount: len(s.records)}, nil +} + +func (s *testAttendanceStore) Checkin(_ types.TenantID, uid types.Uid, checkinID string, req types.AttendanceCheckinRequest) (*types.AttendanceRecord, *types.AttendanceCheckin, error) { + s.checkinRequest = req + now := time.Now().UTC().Round(time.Millisecond) + record := &types.AttendanceRecord{ + RecordID: "evt_record", UserID: uid, Date: req.Date, RuleID: "att_rule_default", ShiftID: "shift_default", + Status: types.AttendanceStatusCheckedIn, Version: 2, CreatedAt: now, UpdatedAt: now, + } + checkin := &types.AttendanceCheckin{ + CheckinID: checkinID, RecordID: record.RecordID, UserID: uid, Date: req.Date, Action: req.Action, + RuleID: record.RuleID, ShiftID: record.ShiftID, ClientTime: req.ClientTime, ServerTime: now, + Result: types.AttendanceCheckinNormal, Location: req.Location, Wifi: req.Wifi, Device: req.Device, + IdempotencyKey: req.IdempotencyKey, CreatedAt: now, + } + record.Checkins = []types.AttendanceCheckin{*checkin} + return record, checkin, nil +} + +func (s *testAttendanceStore) CreateAppeal(_ types.TenantID, uid types.Uid, appeal *types.AttendanceAppeal, idempotencyKey string) (*types.AttendanceAppeal, error) { + s.appeal = appeal + s.idempotencyKey = idempotencyKey + now := time.Now().UTC().Round(time.Millisecond) + appeal.UserID = uid + appeal.Status = types.AttendanceAppealPending + appeal.Version = 1 + appeal.SubmittedAt = now + appeal.CreatedAt = now + appeal.UpdatedAt = now + return appeal, nil +} + +func (s *testAttendanceStore) ListAppeals(_ types.TenantID, _ types.Uid, opts types.AttendanceAppealListOptions) ([]types.AttendanceAppeal, string, error) { + if s.appeal == nil { + return nil, opts.Cursor, nil + } + return []types.AttendanceAppeal{*s.appeal}, opts.Cursor, nil +} + func (s *testMonthlyReportsStore) List(_ types.TenantID, _ types.Uid, _, _, _, _, _ string, _ int) ([]types.MonthlyReport, string, error) { if s.report == nil { return nil, "", nil @@ -2363,6 +2443,82 @@ func TestServeIMOrganizationSearch(t *testing.T) { } } +func TestServeIMAttendanceConfigAndRecords(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 42) + oldAttendance := store.Attendance + now := time.Now().UTC().Round(time.Millisecond) + attendance := &testAttendanceStore{records: []types.AttendanceRecord{{ + RecordID: "evt_record", UserID: 42, Date: time.Date(2026, 8, 31, 0, 0, 0, 0, time.UTC), + RuleID: "att_rule_default", ShiftID: "shift_default", Status: types.AttendanceStatusCheckedIn, + Version: 2, CreatedAt: now, UpdatedAt: now, + }}} + store.Attendance = attendance + defer func() { store.Attendance = oldAttendance }() + + req := httptest.NewRequest(http.MethodGet, "/v1/im/attendance/config?date=2026-08-31", nil) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + serveIMAttendanceConfig(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("config status=%d body=%s", rec.Code, rec.Body.String()) + } + data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + if data["date"] != "2026-08-31" || data["timezone"] != "Asia/Shanghai" { + t.Fatalf("unexpected config: %#v", data) + } + + req = httptest.NewRequest(http.MethodGet, "/v1/im/attendance/records?scope=mine&from=2026-08-01&to=2026-08-31", nil) + req.Header.Set("Authorization", "Bearer token-1") + rec = httptest.NewRecorder() + serveIMAttendanceRecords(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("records status=%d body=%s", rec.Code, rec.Body.String()) + } + data = decodeIMHTTPResponse(t, rec).Data.(map[string]any) + items := data["items"].([]any) + if len(items) != 1 || items[0].(map[string]any)["record_id"] != "evt_record" { + t.Fatalf("unexpected records: %#v", data) + } +} + +func TestServeIMAttendanceCheckinAndAppeal(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 42) + oldAttendance := store.Attendance + attendance := &testAttendanceStore{} + store.Attendance = attendance + defer func() { store.Attendance = oldAttendance }() + + req := httptest.NewRequest(http.MethodPost, "/v1/im/attendance/checkins", strings.NewReader(`{"date":"2026-08-31","action":"check_in","client_time":"2026-08-31T01:00:00Z","location":{"name":"总部"}}`)) + req.Header.Set("Authorization", "Bearer token-1") + req.Header.Set("Idempotency-Key", "att-1") + rec := httptest.NewRecorder() + serveIMAttendanceCheckins(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("checkin status=%d body=%s", rec.Code, rec.Body.String()) + } + data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + if attendance.checkinRequest.Action != types.AttendanceActionCheckIn || data["record"] == nil || data["checkin"] == nil { + t.Fatalf("unexpected checkin: data=%#v req=%#v", data, attendance.checkinRequest) + } + + req = httptest.NewRequest(http.MethodPost, "/v1/im/attendance/appeals", strings.NewReader(`{"record_id":"evt_record","appeal_type":"late","reason":"地铁延误","target_action":"check_in","target_time":"2026-08-31T00:58:00Z"}`)) + req.Header.Set("Authorization", "Bearer token-1") + req.Header.Set("Idempotency-Key", "appeal-1") + rec = httptest.NewRecorder() + serveIMAttendanceAppeals(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("appeal status=%d body=%s", rec.Code, rec.Body.String()) + } + data = decodeIMHTTPResponse(t, rec).Data.(map[string]any) + if attendance.idempotencyKey != "appeal-1" || data["status"] != types.AttendanceAppealPending { + t.Fatalf("unexpected appeal: data=%#v key=%q", data, attendance.idempotencyKey) + } +} + func TestServeIMMeetingsCreate(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() diff --git a/server/main.go b/server/main.go index c042434..cb89653 100644 --- a/server/main.go +++ b/server/main.go @@ -825,6 +825,10 @@ func main() { mux.HandleFunc(config.ApiPath+"v1/im/organization/departments/", serveIMOrganizationDepartmentResource) mux.HandleFunc(config.ApiPath+"v1/im/organization/members", serveIMOrganizationMembers) mux.HandleFunc(config.ApiPath+"v1/im/organization/search", serveIMOrganizationSearch) + mux.HandleFunc(config.ApiPath+"v1/im/attendance/config", serveIMAttendanceConfig) + mux.HandleFunc(config.ApiPath+"v1/im/attendance/records", serveIMAttendanceRecords) + mux.HandleFunc(config.ApiPath+"v1/im/attendance/checkins", serveIMAttendanceCheckins) + mux.HandleFunc(config.ApiPath+"v1/im/attendance/appeals", serveIMAttendanceAppeals) mux.HandleFunc(config.ApiPath+"v1/im/meetings", serveIMMeetings) mux.HandleFunc(config.ApiPath+"v1/im/meetings/", serveIMMeetingResource) mux.HandleFunc(config.ApiPath+"v1/im/reports/abuse/reasons", serveIMAbuseReasons) diff --git a/server/store/store.go b/server/store/store.go index 6fe8488..cb9dc2d 100644 --- a/server/store/store.go +++ b/server/store/store.go @@ -2125,6 +2125,102 @@ func (organizationMapper) Search(tenantID types.TenantID, uid types.Uid, queryTe return tenantAdp.TenantOrganizationSearch(tenantID, uid, strings.TrimSpace(queryText), strings.TrimSpace(resultTypes), strings.TrimSpace(cursor), limit) } +type AttendancePersistenceInterface interface { + Config(tenantID types.TenantID, uid types.Uid, date time.Time) (*types.AttendanceConfig, error) + ListRecords(tenantID types.TenantID, uid types.Uid, opts types.AttendanceRecordListOptions) ([]types.AttendanceRecord, string, *types.AttendanceRecordStats, error) + Checkin(tenantID types.TenantID, uid types.Uid, checkinID string, req types.AttendanceCheckinRequest) (*types.AttendanceRecord, *types.AttendanceCheckin, error) + CreateAppeal(tenantID types.TenantID, uid types.Uid, appeal *types.AttendanceAppeal, idempotencyKey string) (*types.AttendanceAppeal, error) + ListAppeals(tenantID types.TenantID, uid types.Uid, opts types.AttendanceAppealListOptions) ([]types.AttendanceAppeal, string, error) +} + +type attendanceMapper struct{} + +var Attendance AttendancePersistenceInterface + +func (attendanceMapper) Config(tenantID types.TenantID, uid types.Uid, date time.Time) (*types.AttendanceConfig, error) { + if uid.IsZero() || date.IsZero() { + return nil, types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, err + } + return tenantAdp.TenantAttendanceConfig(tenantID, uid, date) +} + +func (attendanceMapper) ListRecords(tenantID types.TenantID, uid types.Uid, opts types.AttendanceRecordListOptions) ([]types.AttendanceRecord, string, *types.AttendanceRecordStats, error) { + if uid.IsZero() { + return nil, "", nil, types.ErrMalformed + } + if opts.Limit <= 0 || opts.Limit > 100 { + opts.Limit = 50 + } + opts.Scope = strings.TrimSpace(opts.Scope) + if opts.Scope == "" { + opts.Scope = "mine" + } + if opts.Scope != "mine" && opts.Scope != "managed" { + return nil, "", nil, types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, "", nil, err + } + return tenantAdp.TenantAttendanceRecordList(tenantID, uid, opts) +} + +func (attendanceMapper) Checkin(tenantID types.TenantID, uid types.Uid, checkinID string, req types.AttendanceCheckinRequest) (*types.AttendanceRecord, *types.AttendanceCheckin, error) { + if uid.IsZero() || strings.TrimSpace(checkinID) == "" || req.Date.IsZero() || req.ClientTime.IsZero() || !validAttendanceAction(req.Action) { + return nil, nil, types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, nil, err + } + return tenantAdp.TenantAttendanceCheckin(tenantID, uid, strings.TrimSpace(checkinID), req) +} + +func (attendanceMapper) CreateAppeal(tenantID types.TenantID, uid types.Uid, appeal *types.AttendanceAppeal, idempotencyKey string) (*types.AttendanceAppeal, error) { + if uid.IsZero() || appeal == nil || strings.TrimSpace(appeal.AppealID) == "" || strings.TrimSpace(appeal.RecordID) == "" || strings.TrimSpace(appeal.Reason) == "" { + return nil, types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, err + } + return tenantAdp.TenantAttendanceAppealCreate(tenantID, uid, appeal, strings.TrimSpace(idempotencyKey)) +} + +func (attendanceMapper) ListAppeals(tenantID types.TenantID, uid types.Uid, opts types.AttendanceAppealListOptions) ([]types.AttendanceAppeal, string, error) { + if uid.IsZero() { + return nil, "", types.ErrMalformed + } + if opts.Limit <= 0 || opts.Limit > 100 { + opts.Limit = 50 + } + opts.Scope = strings.TrimSpace(opts.Scope) + if opts.Scope == "" { + opts.Scope = "mine" + } + if opts.Scope != "mine" && opts.Scope != "managed" { + return nil, "", types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, "", err + } + return tenantAdp.TenantAttendanceAppealList(tenantID, uid, opts) +} + +func validAttendanceAction(action string) bool { + switch strings.TrimSpace(action) { + case types.AttendanceActionCheckIn, types.AttendanceActionCheckOut: + return true + default: + return false + } +} + type MeetingsPersistenceInterface interface { Create(tenantID types.TenantID, uid types.Uid, meeting *types.Meeting, attendeeIDs []types.Uid) (*types.Meeting, error) List(tenantID types.TenantID, uid types.Uid, cursor string, limit int) ([]types.Meeting, string, error) @@ -4490,6 +4586,7 @@ func init() { GroupBusiness = groupBusinessMapper{} Workbench = workbenchMapper{} Organization = organizationMapper{} + Attendance = attendanceMapper{} Meetings = meetingsMapper{} DailyReports = dailyReportsMapper{} MonthlyReports = monthlyReportsMapper{} diff --git a/server/store/types/attendance.go b/server/store/types/attendance.go new file mode 100644 index 0000000..b4b675a --- /dev/null +++ b/server/store/types/attendance.go @@ -0,0 +1,153 @@ +package types + +import "time" + +const ( + AttendanceActionCheckIn = "check_in" + AttendanceActionCheckOut = "check_out" + + AttendanceStatusNotChecked = "not_checked" + AttendanceStatusCheckedIn = "checked_in" + AttendanceStatusNormal = "normal" + AttendanceStatusLate = "late" + AttendanceStatusEarlyLeave = "early_leave" + AttendanceStatusAbnormal = "abnormal" + + AttendanceCheckinNormal = "normal" + AttendanceCheckinLate = "late" + AttendanceCheckinEarlyLeave = "early_leave" + AttendanceCheckinOutsideRange = "outside_range" + + AttendanceAppealPending = "pending" + AttendanceAppealApproved = "approved" + AttendanceAppealRejected = "rejected" + AttendanceAppealCanceled = "canceled" +) + +type AttendanceRule struct { + ID int64 + RuleID string + Name string + Workday bool + LocationRequired bool + WifiRequired bool + AllowOutsideCheckin bool + AppealAllowed bool + AppealDeadline *time.Time + CreatedAt time.Time + UpdatedAt time.Time +} + +type AttendanceShift struct { + ID int64 + ShiftID string + RuleID string + Name string + WorkStart string + WorkEnd string + CheckinStart string + CheckoutEnd string + SortOrder int + CreatedAt time.Time + UpdatedAt time.Time +} + +type AttendanceCheckin struct { + ID int64 + CheckinID string + RecordID string + UserID Uid + Date time.Time + Action string + RuleID string + ShiftID string + ClientTime time.Time + ServerTime time.Time + Result string + Location KVMap + Wifi KVMap + Device KVMap + IdempotencyKey string + CreatedAt time.Time +} + +type AttendanceRecord struct { + ID int64 + RecordID string + UserID Uid + Date time.Time + RuleID string + ShiftID string + Status string + Checkins []AttendanceCheckin + Exceptions []string + AppealStatus string + Version int + CreatedAt time.Time + UpdatedAt time.Time +} + +type AttendanceConfig struct { + Date time.Time + Timezone string + Rule AttendanceRule + Shifts []AttendanceShift + AllowedActions []string + TodayRecord *AttendanceRecord + CanViewManaged bool +} + +type AttendanceRecordListOptions struct { + Scope string + From string + To string + Date string + Status string + DepartmentID string + UserID string + Cursor string + Limit int +} + +type AttendanceRecordStats struct { + ExpectedCount int + CheckedCount int + ExceptionCount int + NotCheckedCount int +} + +type AttendanceCheckinRequest struct { + Date time.Time + Action string + RuleID string + ShiftID string + ClientTime time.Time + Location KVMap + Wifi KVMap + Device KVMap + IdempotencyKey string +} + +type AttendanceAppeal struct { + ID int64 + AppealID string + RecordID string + UserID Uid + AppealType string + Reason string + TargetAction string + TargetTime *time.Time + AttachmentFileIDs []string + Status string + Version int + SubmittedAt time.Time + CreatedAt time.Time + UpdatedAt time.Time +} + +type AttendanceAppealListOptions struct { + Scope string + Status string + Cursor string + Limit int +}