// // MessageCenterModels.swift // suixinkan // import Foundation /// 可解码的 JSON 值,用于承接消息 `extra_data` 原始结构。 enum MessageJSONValue: Decodable, Hashable, Sendable { case string(String) case number(Double) case bool(Bool) case object([String: MessageJSONValue]) case array([MessageJSONValue]) case null init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() if container.decodeNil() { self = .null } else if let value = try? container.decode(Bool.self) { self = .bool(value) } else if let value = try? container.decode(Double.self) { self = .number(value) } else if let value = try? container.decode(String.self) { self = .string(value) } else if let value = try? container.decode([MessageJSONValue].self) { self = .array(value) } else { self = .object(try container.decode([String: MessageJSONValue].self)) } } } /// 消息未读数量响应,用于同步首页红点和桌面图标角标。 struct MessageUnreadCountResponse: Decodable, Equatable, Sendable { let unreadCount: Int enum CodingKeys: String, CodingKey { case unreadCount = "unread_count" } init(unreadCount: Int = 0) { self.unreadCount = max(unreadCount, 0) } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) unreadCount = max(try container.decodeLossyInt(forKey: .unreadCount) ?? 0, 0) } } /// 全部标记已读响应,包含本次更新数量和操作后的未读数量。 struct MessageReadAllResponse: Decodable, Equatable, Sendable { let updatedCount: Int let unreadCount: Int enum CodingKeys: String, CodingKey { case updatedCount = "updated_count" case unreadCount = "unread_count" } init(updatedCount: Int = 0, unreadCount: Int = 0) { self.updatedCount = max(updatedCount, 0) self.unreadCount = max(unreadCount, 0) } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) updatedCount = max(try container.decodeLossyInt(forKey: .updatedCount) ?? 0, 0) unreadCount = max(try container.decodeLossyInt(forKey: .unreadCount) ?? 0, 0) } } /// 消息中心列表响应,对齐 Android `MsgResponse`。 struct MessageListResponse: Decodable, Equatable, Sendable { let hasMore: Bool let lastId: Int let items: [MessageItem] let isRefresh: Bool enum CodingKeys: String, CodingKey { case hasMore = "has_more" case lastId = "last_id" case items case isRefresh = "is_refresh" } init(hasMore: Bool = false, lastId: Int = 0, items: [MessageItem] = [], isRefresh: Bool = false) { self.hasMore = hasMore self.lastId = lastId self.items = items self.isRefresh = isRefresh } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) hasMore = try container.decodeLossyBool(forKey: .hasMore) ?? false lastId = try container.decodeLossyInt(forKey: .lastId) ?? 0 items = (try? container.decodeIfPresent([MessageItem].self, forKey: .items)) ?? [] isRefresh = try container.decodeLossyBool(forKey: .isRefresh) ?? false } } /// 单条消息实体,对齐 Android `MsgEntity`。 struct MessageItem: Decodable, Hashable, Sendable { let id: Int let receiverId: Int let receiverType: String let type: Int let typeName: String let title: String let content: String let pushAt: String let isRead: Bool let readAt: String let pushChannel: Int let pushChannelName: String let extraData: [String: MessageJSONValue]? let createdAt: String let updatedAt: String enum CodingKeys: String, CodingKey { case id case receiverId = "receiver_id" case receiverType = "receiver_type" case type case typeName = "type_name" case title case content case pushAt = "push_at" case isRead = "is_read" case readAt = "read_at" case pushChannel = "push_channel" case pushChannelName = "push_channel_name" case extraData = "extra_data" case createdAt = "created_at" case updatedAt = "updated_at" } init( id: Int = 0, receiverId: Int = 0, receiverType: String = "", type: Int = 0, typeName: String = "", title: String = "", content: String = "", pushAt: String = "", isRead: Bool = false, readAt: String = "", pushChannel: Int = 0, pushChannelName: String = "", extraData: [String: MessageJSONValue]? = nil, createdAt: String = "", updatedAt: String = "" ) { self.id = id self.receiverId = receiverId self.receiverType = receiverType self.type = type self.typeName = typeName self.title = title self.content = content self.pushAt = pushAt self.isRead = isRead self.readAt = readAt self.pushChannel = pushChannel self.pushChannelName = pushChannelName self.extraData = extraData self.createdAt = createdAt self.updatedAt = updatedAt } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.decodeLossyInt(forKey: .id) ?? 0 receiverId = try container.decodeLossyInt(forKey: .receiverId) ?? 0 receiverType = try container.decodeLossyString(forKey: .receiverType) type = try container.decodeLossyInt(forKey: .type) ?? 0 typeName = try container.decodeLossyString(forKey: .typeName) title = try container.decodeLossyString(forKey: .title) content = try container.decodeLossyString(forKey: .content) pushAt = try container.decodeLossyString(forKey: .pushAt) isRead = try container.decodeLossyBool(forKey: .isRead) ?? false readAt = try container.decodeLossyString(forKey: .readAt) pushChannel = try container.decodeLossyInt(forKey: .pushChannel) ?? 0 pushChannelName = try container.decodeLossyString(forKey: .pushChannelName) extraData = try? container.decodeIfPresent([String: MessageJSONValue].self, forKey: .extraData) createdAt = try container.decodeLossyString(forKey: .createdAt) updatedAt = try container.decodeLossyString(forKey: .updatedAt) } /// Android 列表标题使用 `type_name`。 var displayTitle: String { typeName.trimmingCharacters(in: .whitespacesAndNewlines) } /// Android 列表优先展示 `push_at`,否则格式化 `created_at`。 var displayTime: String { let pushTime = pushAt.trimmingCharacters(in: .whitespacesAndNewlines) if !pushTime.isEmpty { return pushTime } return MessageDateFormatter.formatDateTime(createdAt) ?? createdAt } /// 返回已读状态的消息副本。 func markedRead() -> MessageItem { MessageItem( id: id, receiverId: receiverId, receiverType: receiverType, type: type, typeName: typeName, title: title, content: content, pushAt: pushAt, isRead: true, readAt: readAt, pushChannel: pushChannel, pushChannelName: pushChannelName, extraData: extraData, createdAt: createdAt, updatedAt: updatedAt ) } } /// 消息删除请求体,对齐 Android `MsgDeleteRequest`。 struct MessageDeleteRequest: Encodable, Sendable { let id: Int } /// 消息中心日期显示工具,对齐 Android `DateUtils.formatDateTime` 的用户可见规则。 enum MessageDateFormatter { private static let calendar = Calendar.current static func formatDateTime(_ input: String?) -> String? { guard let input = input?.trimmingCharacters(in: .whitespacesAndNewlines), !input.isEmpty else { return nil } guard let date = parseDate(input) else { return nil } let today = Date() let startOfInput = calendar.startOfDay(for: date) let startOfToday = calendar.startOfDay(for: today) let dayDifference = calendar.dateComponents([.day], from: startOfInput, to: startOfToday).day ?? 0 let inputComponents = calendar.dateComponents([.year, .month, .day, .weekday], from: date) let todayComponents = calendar.dateComponents([.year, .month], from: today) if dayDifference == 0 { return "今天" } if (1 ... 6).contains(dayDifference), let weekday = inputComponents.weekday { return weekdayName(weekday) } if inputComponents.year == todayComponents.year, inputComponents.month == todayComponents.month { return "本月\(inputComponents.day ?? 0)日" } if inputComponents.year == todayComponents.year { return "\(inputComponents.month ?? 0)月\(inputComponents.day ?? 0)日" } return "\(inputComponents.year ?? 0)年\(inputComponents.month ?? 0)月\(inputComponents.day ?? 0)日" } private static func parseDate(_ input: String) -> Date? { if let timestamp = Double(input) { let seconds = timestamp > 1_000_000_000_000 ? timestamp / 1000 : timestamp return Date(timeIntervalSince1970: seconds) } let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") formatter.timeZone = .current for format in ["yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd'T'HH:mm:ss", "yyyy-MM-dd"] { formatter.dateFormat = format if let date = formatter.date(from: input) { return date } } return nil } private static func weekdayName(_ weekday: Int) -> String { switch weekday { case 1: return "周日" case 2: return "周一" case 3: return "周二" case 4: return "周三" case 5: return "周四" case 6: return "周五" case 7: return "周六" default: return "" } } } private extension KeyedDecodingContainer { func decodeLossyString(forKey key: Key) throws -> String { if let value = try? decodeIfPresent(String.self, forKey: key) { return value } if let value = try? decodeIfPresent(Int.self, forKey: key) { return String(value) } if let value = try? decodeIfPresent(Double.self, forKey: key) { return String(value) } if let value = try? decodeIfPresent(Bool.self, forKey: key) { return value ? "true" : "false" } return "" } func decodeLossyInt(forKey key: Key) throws -> Int? { if let value = try? decodeIfPresent(Int.self, forKey: key) { return value } if let value = try? decodeIfPresent(Double.self, forKey: key) { return Int(value) } if let value = try? decodeIfPresent(String.self, forKey: key) { let text = value.trimmingCharacters(in: .whitespacesAndNewlines) if let intValue = Int(text) { return intValue } if let doubleValue = Double(text) { return Int(doubleValue) } } return nil } func decodeLossyBool(forKey key: Key) throws -> Bool? { if let value = try? decodeIfPresent(Bool.self, forKey: key) { return value } if let value = try? decodeIfPresent(Int.self, forKey: key) { return value != 0 } if let value = try? decodeIfPresent(String.self, forKey: key) { let text = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() if ["true", "1", "yes"].contains(text) { return true } if ["false", "0", "no"].contains(text) { return false } } return nil } }