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