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
+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