feat: add attendance APIs

This commit is contained in:
richjohnrambo
2026-08-31 13:27:12 +08:00
parent 9f647de4d0
commit cb7dc36cad
10 changed files with 1749 additions and 12 deletions
+19 -2
View File
@@ -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
<img src="images/会议通知-发起人状态.png" alt="会议通知-发起人状态" width="360">
@@ -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}` |
+120 -4
View File
@@ -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;
+5
View File
@@ -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)
+125 -4
View File
@@ -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(
+702 -1
View File
@@ -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<?")
args = append(args, parsed)
}
limit := opts.Limit
if limit <= 0 || limit > 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, &current, `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<?")
args = append(args, parsed)
}
limit := opts.Limit
if limit <= 0 || limit > 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
+368 -1
View File
@@ -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
+156
View File
@@ -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()
+4
View File
@@ -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)
+97
View File
@@ -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{}
+153
View File
@@ -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
}