提示信息
# 同伴 App — Swift 用户模块、个人资料、设置
> 覆盖:个人主页(我的 Tab)、编辑资料、他人主页、黑名单、举报、账号安全、隐私设置、注销。
---
## 一、API 清单
| 接口 | 方法 | 路径 | 说明 |
|---|---|---|---|
| 获取编辑页资料 | GET | `/user/profile/edit` | 返回 `LoginUser`(与登录响应相同结构) |
| 修改单个字段 | POST | `/user/profile/update-field` | `{type, value}` |
| 获取他人主页 | GET | `/user/profile/detail?uid=xxx` | 返回 `UserProfileDetail` |
| 批量获取基础资料 | POST | `/user/profile/openim-batch` | `{user_ids, scene}` |
| 拉黑/取消拉黑 | POST | `/user/block` | `{uid, block: true/false}` |
| 获取黑名单 | GET | `/user/block/list` | |
| 举报 | POST | `/user/report` | `{uid, reason}` |
| 修改手机号 | POST | `/user/security/update-phone` | `{phone, code}` |
| 修改密码 | POST | `/user/security/update-password` | `{phone, code, password}` |
| 注销账号 | POST | `/user/account/cancel` | `{reason}` |
---
## 二、数据模型
```swift
// UserModels.swift
// ── 他人主页 ─────────────────────────────────────────────────────────
struct UserProfileDetail: Decodable, Sendable {
let uid: Int
let nickname: String
let avatar: String
let gender: Int // 1=男 2=女 0=未知
let age: Int
let residence: String // 居住地显示文本,例 "上海"
let bio: String
let mbti: String
let isVerified: Bool
let isBlocked: Bool // 当前用户是否已拉黑TA
let attribute: String // 属性标签文本
let ipLocation: String
let userStatus: Int // 0=正常 1=封禁 2=注销
}
// ── 批量基础资料 ──────────────────────────────────────────────────────
struct UserBasicInfo: Decodable, Sendable {
let uid: Int
let avatar: String
let username: String
let isCertified: Bool
let isOfficial: Bool
// 仅 scene=detail 时返回
let isBlocked: Bool?
let isOnline: Bool?
}
// ── 黑名单条目 ────────────────────────────────────────────────────────
struct BlockedUser: Decodable, Sendable {
let uid: Int
let nickname: String
let avatar: String
let blockTime: Int // Unix 时间戳
}
// ── 资料可编辑字段枚举 ─────────────────────────────────────────────────
enum ProfileField: String {
case nickname = "nickname"
case gender = "gender"
case bio = "bio"
case mbti = "mbti"
case residence = "residence" // value 为 JSON 序列化的 Residence 对象
case avatar = "avatar" // value 为 objectKey
case orientation = "orientation" // value 为数字字符串
case birthDate = "birth_date" // value 为 "yyyy-MM-dd"
case attribute = "attribute"
}
```
---
## 三、UserProfileCache(SQLite 用户资料缓存)
与 Flutter 的 `user_profile_cache.dart` 逻辑对齐,用 GRDB 实现:
```swift
// UserProfileCache.swift
// 目的:IM 会话列表/聊天页快速显示头像昵称,降级网络请求
import GRDB
struct UserProfileRecord: Codable, FetchableRecord, PersistableRecord {
static var databaseTableName = "user_profiles"
let uid: Int
var avatar: String
var username: String
var isCertified: Bool
var updatedAt: Int // Unix 时间戳
}
actor UserProfileCache {
static let shared = UserProfileCache()
private var db: DatabaseQueue?
func initialize(userId: String) throws {
let dir = try FileManager.default.url(
for: .documentDirectory, in: .userDomainMask,
appropriateFor: nil, create: true
).appendingPathComponent("cache_\(userId)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
let dbPath = dir.appendingPathComponent("user_profiles.sqlite").path
db = try DatabaseQueue(path: dbPath)
try db?.write { db in
try db.create(table: "user_profiles", ifNotExists: true) { t in
t.column("uid", .integer).primaryKey()
t.column("avatar", .text).notNull().defaults(to: "")
t.column("username", .text).notNull().defaults(to: "")
t.column("isCertified", .boolean).notNull().defaults(to: false)
t.column("updatedAt", .integer).notNull().defaults(to: 0)
}
}
}
func saveProfiles(_ profiles: [UserBasicInfo]) throws {
guard let db else { return }
let now = Int(Date().timeIntervalSince1970)
try db.write { db in
for p in profiles {
let record = UserProfileRecord(
uid: p.uid, avatar: p.avatar, username: p.username,
isCertified: p.isCertified, updatedAt: now
)
try record.save(db)
}
}
}
func getProfiles(_ uids: [Int]) throws -> [UserBasicInfo] {
guard let db else { return [] }
return try db.read { db in
try UserProfileRecord.filter(uids.contains(Column("uid")))
.fetchAll(db)
.map { r in
UserBasicInfo(uid: r.uid, avatar: r.avatar, username: r.username,
isCertified: r.isCertified, isOfficial: false,
isBlocked: nil, isOnline: nil)
}
}
}
func close() {
db = nil
}
}
```
---
## 四、我的 Tab — ProfileView
```swift
// ProfileView.swift
struct ProfileView: View {
@State private var vm = ProfileViewModel()
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 0) {
// 头像 + 昵称卡
avatarSection
// 资料标签
tagsSection
// 功能入口列表
menuSection
}
}
.navigationTitle("我的")
.navigationBarTitleDisplayMode(.large)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
NavigationLink(destination: SettingsView()) {
Image(systemName: "gearshape")
}
}
}
}
.task { await vm.loadProfile() }
}
// ── 头像区 ────────────────────────────────────────────────────────
private var avatarSection: some View {
VStack(spacing: 12) {
AsyncImage(url: URL(string: vm.profile?.avatar ?? "")) { image in
image.resizable().scaledToFill()
} placeholder: {
Circle().fill(.secondary.opacity(0.2))
}
.frame(width: 80, height: 80)
.clipShape(Circle())
VStack(spacing: 4) {
HStack(spacing: 6) {
Text(vm.profile?.nickname ?? "").font(.title3.bold())
if vm.profile?.isVerified == true {
Image(systemName: "checkmark.seal.fill").foregroundStyle(.blue)
}
}
if let mbti = vm.profile?.mbti, !mbti.isEmpty {
Text(mbti)
.font(.caption)
.padding(.horizontal, 8).padding(.vertical, 2)
.glassEffect()
}
}
NavigationLink("编辑资料") {
EditProfileView(profile: vm.profile)
}
.glassEffect(.regular.interactive())
.padding(.top, 4)
}
.padding(.vertical, 24)
}
private var tagsSection: some View {
// 地区 / 年龄 / 取向等标签横排
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(vm.profileTags, id: \.self) { tag in
Text(tag).font(.caption)
.padding(.horizontal, 10).padding(.vertical, 4)
.background(.secondary.opacity(0.15), in: Capsule())
}
}.padding(.horizontal)
}
}
private var menuSection: some View {
VStack(spacing: 0) {
Divider().padding(.vertical, 8)
// 通知设置、隐私、帮助等入口
Group {
menuRow(icon: "bell", title: "通知设置") { }
menuRow(icon: "eye.slash", title: "隐私设置") { }
menuRow(icon: "questionmark.circle", title: "帮助中心") { }
}
}
}
private func menuRow(icon: String, title: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack {
Image(systemName: icon).frame(width: 24)
Text(title)
Spacer()
Image(systemName: "chevron.right").foregroundStyle(.secondary)
}
.padding()
}
.foregroundStyle(.primary)
}
}
// ── ViewModel ─────────────────────────────────────────────────────────
@MainActor
@Observable
final class ProfileViewModel {
var profile: LoginUser?
var isLoading = false
var profileTags: [String] {
var tags: [String] = []
if let r = profile?.residence {
if !r.cityName.isEmpty { tags.append(r.cityName) }
}
if let age = profile?.age { tags.append("\(age)岁") }
if let mbti = profile?.mbti, !mbti.isEmpty { tags.append(mbti) }
return tags
}
func loadProfile() async {
guard let cached = AppStorage.getString(StorageKeys.userProfile),
let data = cached.data(using: .utf8),
let user = try? JSONDecoder().decode(LoginUser.self, from: data) else { return }
profile = user
}
func refresh() async {
isLoading = true
defer { isLoading = false }
if let user = try? await APIClient.shared.get("/user/profile/edit", as: LoginUser.self) {
profile = user
if let data = try? JSONEncoder().encode(user),
let str = String(data: data, encoding: .utf8) {
AppStorage.setString(StorageKeys.userProfile, str)
}
}
}
}
```
---
## 五、编辑资料页(EditProfileView)
所有字段均调用 `POST /user/profile/update-field`,逐字段保存(不是整体提交)。
```swift
// EditProfileView.swift
struct EditProfileView: View {
let profile: LoginUser?
@State private var vm: EditProfileViewModel
init(profile: LoginUser?) {
self.profile = profile
_vm = State(initialValue: EditProfileViewModel(profile: profile))
}
var body: some View {
List {
// 头像
Section {
HStack {
Spacer()
avatarButton
Spacer()
}
}
// 基础信息
Section("基础信息") {
editRow(label: "昵称", value: vm.nickname) {
vm.editingField = .nickname
}
editRow(label: "性别", value: vm.genderLabel) {
vm.editingField = .gender
}
editRow(label: "生日", value: vm.birthDate) {
vm.showBirthPicker = true
}
editRow(label: "常住地", value: vm.residenceLabel) {
vm.showRegionPicker = true
}
}
// 性格标签
Section("性格") {
editRow(label: "MBTI", value: vm.mbti.isEmpty ? "未填写" : vm.mbti) {
vm.editingField = .mbti
}
}
// 个人简介
Section("简介") {
editRow(label: "个性签名", value: vm.bio.isEmpty ? "写点什么..." : vm.bio, multiline: true) {
vm.editingField = .bio
}
}
}
.navigationTitle("编辑资料")
.navigationBarTitleDisplayMode(.inline)
// 单字段编辑 Sheet
.sheet(item: $vm.editingField) { field in
FieldEditSheet(field: field, currentValue: vm.value(for: field)) { newValue in
Task { await vm.updateField(field, value: newValue) }
}
}
// 生日选择 Sheet
.sheet(isPresented: $vm.showBirthPicker) {
DatePickerSheet(date: vm.birthDateValue) { date in
Task { await vm.updateBirthDate(date) }
}
}
// 省市选择 Sheet
.sheet(isPresented: $vm.showRegionPicker) {
RegionPickerSheet(current: vm.residence) { region in
Task { await vm.updateResidence(region) }
}
}
}
// 头像按钮
private var avatarButton: some View {
Button {
Task { @MainActor in
guard let vc = UIApplication.topViewController else { return }
do {
let result = try await MediaPipeline.pickAndUploadAvatar(from: vc)
await vm.updateField(.avatar, value: result.objectKey)
} catch { }
}
} label: {
AsyncImage(url: URL(string: vm.avatarURL)) { img in
img.resizable().scaledToFill()
} placeholder: {
Circle().fill(.secondary.opacity(0.2))
}
.frame(width: 80, height: 80)
.clipShape(Circle())
.overlay(alignment: .bottomTrailing) {
Image(systemName: "camera.fill")
.font(.caption)
.padding(6)
.glassEffect()
}
}
}
private func editRow(
label: String, value: String,
multiline: Bool = false,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
HStack {
Text(label).foregroundStyle(.primary)
Spacer()
Text(value)
.foregroundStyle(.secondary)
.lineLimit(multiline ? 2 : 1)
Image(systemName: "chevron.right").foregroundStyle(.tertiary).font(.caption)
}
}
.foregroundStyle(.primary)
}
}
// ── EditProfileViewModel ───────────────────────────────────────────────
@MainActor
@Observable
final class EditProfileViewModel {
var nickname: String
var bio: String
var mbti: String
var birthDate: String
var avatarURL: String
var gender: Int
var residence: Residence?
var editingField: ProfileField?
var showBirthPicker = false
var showRegionPicker = false
var genderLabel: String {
switch gender {
case 1: return "男"
case 2: return "女"
default: return "未设置"
}
}
var residenceLabel: String {
guard let r = residence else { return "未设置" }
return [r.provinceName, r.cityName].filter { !$0.isEmpty }.joined(separator: " ")
}
var birthDateValue: Date {
let fmt = DateFormatter(); fmt.dateFormat = "yyyy-MM-dd"
return fmt.date(from: birthDate) ?? Date()
}
init(profile: LoginUser?) {
nickname = profile?.nickname ?? ""
bio = profile?.bio ?? ""
mbti = profile?.mbti ?? ""
birthDate = profile?.birthDate ?? ""
avatarURL = profile?.avatar ?? ""
gender = profile?.gender ?? 0
residence = profile?.residence
}
func value(for field: ProfileField) -> String {
switch field {
case .nickname: return nickname
case .bio: return bio
case .mbti: return mbti
default: return ""
}
}
func updateField(_ field: ProfileField, value: String) async {
do {
try await APIClient.shared.post("/user/profile/update-field", body: [
"type": field.rawValue,
"value": value
] as [String: Any])
// 更新本地状态
switch field {
case .nickname: nickname = value
case .bio: bio = value
case .mbti: mbti = value
case .avatar: break // URL 需要重新签名后更新,此处先不更新
default: break
}
// 更新 MMKV 缓存
await refreshLocalCache()
} catch let e as AppAPIError {
// Toast 展示错误
}
}
func updateBirthDate(_ date: Date) async {
let fmt = DateFormatter(); fmt.dateFormat = "yyyy-MM-dd"
let str = fmt.string(from: date)
await updateField(.birthDate, value: str)
birthDate = str
}
func updateResidence(_ region: Residence) async {
guard let json = try? JSONEncoder().encode(region),
let str = String(data: json, encoding: .utf8) else { return }
await updateField(.residence, value: str)
residence = region
}
private func refreshLocalCache() async {
if let user = try? await APIClient.shared.get("/user/profile/edit", as: LoginUser.self),
let data = try? JSONEncoder().encode(user),
let str = String(data: data, encoding: .utf8) {
AppStorage.setString(StorageKeys.userProfile, str)
}
}
}
```
---
## 六、他人主页(UserProfileView)
```swift
// UserProfileView.swift
struct UserProfileView: View {
let uid: Int
@State private var vm = UserProfileViewModel()
var body: some View {
ScrollView {
if let detail = vm.detail {
VStack(spacing: 16) {
// 头像 + 状态
profileHeader(detail)
// 简介
if !detail.bio.isEmpty {
Text(detail.bio)
.font(.body)
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(.background.secondary, in: RoundedRectangle(cornerRadius: 12))
.padding(.horizontal)
}
// 操作按钮
actionButtons(detail)
}
} else if vm.isLoading {
ProgressView().padding(.top, 60)
}
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
moreMenu
}
}
.task { await vm.load(uid: uid) }
}
private func profileHeader(_ d: UserProfileDetail) -> some View {
VStack(spacing: 12) {
AsyncImage(url: URL(string: d.avatar)) { img in
img.resizable().scaledToFill()
} placeholder: {
Circle().fill(.secondary.opacity(0.2))
}
.frame(width: 80, height: 80).clipShape(Circle())
HStack(spacing: 6) {
Text(d.nickname).font(.title3.bold())
if d.isVerified {
Image(systemName: "checkmark.seal.fill").foregroundStyle(.blue)
}
}
// 标签:MBTI / 居住地 / IP 归属
HStack(spacing: 8) {
if !d.mbti.isEmpty { tag(d.mbti) }
if !d.residence.isEmpty { tag(d.residence) }
if !d.ipLocation.isEmpty { tag("IP: \(d.ipLocation)") }
}
}
.padding(.top, 24)
}
private func actionButtons(_ d: UserProfileDetail) -> some View {
GlassEffectContainer {
HStack(spacing: 12) {
// 发私信(仅 isImOpened 时才有,否则需要通过信号)
Button {
// 打开聊天
} label: {
Label("私信", systemImage: "message")
}
.glassEffect(.regular.interactive())
// 拉黑
Button {
Task { await vm.toggleBlock(uid: uid) }
} label: {
Label(d.isBlocked ? "已拉黑" : "拉黑", systemImage: d.isBlocked ? "person.fill.xmark" : "person.badge.minus")
}
.glassEffect(.regular.interactive())
}
}
.padding(.horizontal)
}
private var moreMenu: some View {
Menu {
Button(role: .destructive) {
Task { await vm.report(uid: uid) }
} label: {
Label("举报", systemImage: "exclamationmark.triangle")
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
private func tag(_ text: String) -> some View {
Text(text).font(.caption)
.padding(.horizontal, 8).padding(.vertical, 3)
.background(.secondary.opacity(0.15), in: Capsule())
}
}
@MainActor
@Observable
final class UserProfileViewModel {
var detail: UserProfileDetail?
var isLoading = false
func load(uid: Int) async {
isLoading = true
defer { isLoading = false }
detail = try? await APIClient.shared.get(
"/user/profile/detail", params: ["uid": uid],
as: UserProfileDetail.self
)
}
func toggleBlock(uid: Int) async {
guard let current = detail else { return }
let newState = !current.isBlocked
if (try? await APIClient.shared.post("/user/block",
body: ["uid": uid, "block": newState] as [String: Any])) != nil {
detail = UserProfileDetail(
uid: current.uid, nickname: current.nickname, avatar: current.avatar,
gender: current.gender, age: current.age, residence: current.residence,
bio: current.bio, mbti: current.mbti, isVerified: current.isVerified,
isBlocked: newState, attribute: current.attribute,
ipLocation: current.ipLocation, userStatus: current.userStatus
)
}
}
func report(uid: Int) async {
// 弹出 ActionSheet 选举报原因
}
}
```
---
## 七、设置页(SettingsView)
```swift
// SettingsView.swift
struct SettingsView: View {
@Environment(AppCoordinator.self) private var coordinator
@State private var showLogoutAlert = false
var body: some View {
List {
Section("账号与安全") {
NavigationLink("账号安全") { AccountSecurityView() }
NavigationLink("隐私设置") { PrivacySettingsView() }
NavigationLink("黑名单") { BlocklistView() }
}
Section("通用") {
NavigationLink("缓存管理") { CacheManagementView() }
NavigationLink("关于同伴") { AboutView() }
}
Section {
Button("退出登录", role: .destructive) {
showLogoutAlert = true
}
}
}
.navigationTitle("设置")
.alert("退出登录", isPresented: $showLogoutAlert) {
Button("退出", role: .destructive) {
Task { await AuthService.shared.logout() }
}
Button("取消", role: .cancel) { }
}
}
}
```
---
## 八、账号安全(修改手机号 / 修改密码 / 注销)
```swift
// AccountSecurityView.swift
struct AccountSecurityView: View {
@State private var vm = AccountSecurityViewModel()
var body: some View {
List {
Section {
Button("修改手机号") { vm.sheet = .changePhone }
Button("修改密码") { vm.sheet = .changePassword }
}
Section {
Button("注销账号", role: .destructive) { vm.sheet = .cancelAccount }
}
}
.navigationTitle("账号安全")
.sheet(item: $vm.sheet) { sheet in
switch sheet {
case .changePhone: ChangePhoneView()
case .changePassword: ChangePasswordView()
case .cancelAccount: CancelAccountView()
}
}
}
}
// ── 修改手机号 ─────────────────────────────────────────────────────────
@MainActor
@Observable
final class AccountSecurityViewModel {
enum SheetType: String, Identifiable {
case changePhone, changePassword, cancelAccount
var id: String { rawValue }
}
var sheet: SheetType?
}
// ── 注销账号 ──────────────────────────────────────────────────────────
struct CancelAccountView: View {
@State private var reason = ""
@State private var showConfirm = false
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
VStack(alignment: .leading, spacing: 16) {
Text("注销账号后,您的所有数据将被永久删除,且无法恢复。")
.foregroundStyle(.secondary)
TextEditor(text: $reason)
.frame(height: 120)
.padding(8)
.background(.background.secondary, in: RoundedRectangle(cornerRadius: 12))
.overlay(
reason.isEmpty ? Text("请输入注销原因(可选)").foregroundStyle(.tertiary).padding(12) : nil,
alignment: .topLeading
)
Spacer()
Button("确认注销", role: .destructive) {
showConfirm = true
}
.frame(maxWidth: .infinity)
.glassEffect()
}
.padding()
.navigationTitle("注销账号")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("取消") { dismiss() }
}
}
.alert("确认注销?", isPresented: $showConfirm) {
Button("注销账号", role: .destructive) {
Task {
try? await APIClient.shared.post(
"/user/account/cancel",
body: ["reason": reason] as [String: Any]
)
await AuthService.shared.logout()
}
}
Button("取消", role: .cancel) { }
} message: {
Text("此操作不可撤销,所有数据将被永久删除。")
}
}
}
}
```
---
## 九、举报(ReportView)
从他人主页和聊天长按菜单触发:
```swift
// ReportView.swift
struct ReportView: View {
let uid: Int
@State private var selectedReason: String?
@Environment(\.dismiss) private var dismiss
static let reasons = [
"色情低俗内容",
"骚扰谩骂",
"诈骗欺诈",
"虚假信息",
"垃圾广告",
"其他"
]
var body: some View {
NavigationStack {
List(ReportView.reasons, id: \.self) { reason in
Button {
selectedReason = reason
} label: {
HStack {
Text(reason)
Spacer()
if selectedReason == reason {
Image(systemName: "checkmark").foregroundStyle(.accent)
}
}
}
.foregroundStyle(.primary)
}
.navigationTitle("举报")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("取消") { dismiss() }
}
ToolbarItem(placement: .topBarTrailing) {
Button("提交") {
guard let reason = selectedReason else { return }
Task {
try? await APIClient.shared.post(
"/user/report",
body: ["uid": uid, "reason": reason] as [String: Any]
)
dismiss()
}
}
.disabled(selectedReason == nil)
}
}
}
.presentationDetents([.medium])
}
}
```
---
## 十、黑名单(BlocklistView)
```swift
// BlocklistView.swift
struct BlocklistView: View {
@State private var blockedUsers: [BlockedUser] = []
@State private var isLoading = true
var body: some View {
List {
if isLoading {
ProgressView()
} else if blockedUsers.isEmpty {
ContentUnavailableView("黑名单为空", systemImage: "person.crop.circle.badge.checkmark")
} else {
ForEach(blockedUsers, id: \.uid) { user in
HStack {
AsyncImage(url: URL(string: user.avatar)) { img in
img.resizable().scaledToFill()
} placeholder: {
Circle().fill(.secondary.opacity(0.2))
}
.frame(width: 40, height: 40).clipShape(Circle())
VStack(alignment: .leading, spacing: 2) {
Text(user.nickname).font(.body)
Text(Date(timeIntervalSince1970: TimeInterval(user.blockTime)),
style: .relative)
.font(.caption).foregroundStyle(.secondary)
}
Spacer()
Button("移除") {
Task { await removeBlock(uid: user.uid) }
}
.glassEffect(.regular.interactive())
}
}
}
}
.navigationTitle("黑名单")
.task { await loadBlocklist() }
}
private func loadBlocklist() async {
isLoading = true
blockedUsers = (try? await APIClient.shared.get("/user/block/list",
as: [BlockedUser].self)) ?? []
isLoading = false
}
private func removeBlock(uid: Int) async {
try? await APIClient.shared.post("/user/block",
body: ["uid": uid, "block": false] as [String: Any])
blockedUsers.removeAll { $0.uid == uid }
}
}
```
---
## 十一、批量用户资料查询(IM 会话列表使用)
```swift
// ConversationListViewModel 中的用法示例
func loadUserProfiles(uids: [Int]) async {
// 先查 SQLite 缓存
let cached = (try? await UserProfileCache.shared.getProfiles(uids)) ?? []
let cachedUIDs = Set(cached.map(\.uid))
let missingUIDs = uids.filter { !cachedUIDs.contains($0) }
// 仅对缺失的 UID 发起网络请求
guard !missingUIDs.isEmpty else { return }
let fetched = (try? await APIClient.shared.post(
"/user/profile/openim-batch",
body: ["user_ids": missingUIDs, "scene": "list"] as [String: Any],
as: [UserBasicInfo].self
)) ?? []
// 写入缓存
try? await UserProfileCache.shared.saveProfiles(fetched)
}
```