feat: add bootstrap entry badges

This commit is contained in:
richjohnrambo
2026-08-20 15:59:16 +08:00
parent d467323ecb
commit 6db2938672
9 changed files with 707 additions and 24 deletions
+2
View File
@@ -432,10 +432,12 @@ type TenantBusinessAdapter interface {
TenantSystemNotificationCreate(t.TenantID, *t.SystemNotification) error
TenantSystemNotificationList(t.TenantID, t.Uid, string, int) ([]t.SystemNotification, string, error)
TenantSystemNotificationUnreadCount(t.TenantID, t.Uid) (int, error)
TenantSystemNotificationMarkRead(t.TenantID, t.Uid, string, time.Time) error
TenantFriendRequestCreate(t.TenantID, *t.FriendRequest) (*t.FriendRequest, error)
TenantFriendRequestList(t.TenantID, t.Uid, string, string, string, int) ([]t.FriendRequestListItem, string, error)
TenantFriendRequestPendingCount(t.TenantID, t.Uid) (int, error)
TenantFriendRequestLatestBetween(t.TenantID, t.Uid, t.Uid) (*t.FriendRequest, error)
TenantFriendRequestAccept(t.TenantID, string, t.Uid, time.Time) (*t.FriendRequest, error)
TenantFriendRequestResolve(t.TenantID, string, t.Uid, string, time.Time) (*t.FriendRequest, error)
+52
View File
@@ -6221,6 +6221,32 @@ func (a *adapter) TenantSystemNotificationList(id t.TenantID, uid t.Uid, cursor
return notifications, nextCursor, nil
}
func (a *adapter) TenantSystemNotificationUnreadCount(id t.TenantID, uid t.Uid) (int, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return 0, err
}
if tenantAdp != a {
return tenantAdp.TenantSystemNotificationUnreadCount(id, uid)
}
}
if err := validTenant(id); err != nil || uid.IsZero() {
return 0, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var count int
if err := a.db.GetContext(ctx, &count,
`SELECT COUNT(*) FROM im_system_notifications WHERE user_id=? AND read_at IS NULL`,
decodeUID(uid)); err != nil {
return 0, err
}
return count, nil
}
func (a *adapter) TenantSystemNotificationMarkRead(id t.TenantID, uid t.Uid, eventID string, readAt time.Time) error {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
@@ -6430,6 +6456,32 @@ func (a *adapter) TenantFriendRequestList(id t.TenantID, uid t.Uid, direction, s
return items, nextCursor, nil
}
func (a *adapter) TenantFriendRequestPendingCount(id t.TenantID, uid t.Uid) (int, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return 0, err
}
if tenantAdp != a {
return tenantAdp.TenantFriendRequestPendingCount(id, uid)
}
}
if err := validTenant(id); err != nil || uid.IsZero() {
return 0, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var count int
if err := a.db.GetContext(ctx, &count,
`SELECT COUNT(*) FROM im_friend_requests WHERE target_id=? AND status=? AND expires_at>?`,
decodeUID(uid), t.FriendRequestPending, t.TimeNow()); err != nil {
return 0, err
}
return count, nil
}
func (a *adapter) TenantFriendRequestLatestBetween(id t.TenantID, uid1, uid2 t.Uid) (*t.FriendRequest, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
+91 -2
View File
@@ -391,6 +391,7 @@ type imGroupCreateRequest struct {
Name string `json:"name"`
MemberUserIDs []string `json:"member_user_ids,omitempty"`
AvatarFileID string `json:"avatar_file_id,omitempty"`
Description string `json:"description,omitempty"`
}
type imGroupPatchRequest struct {
@@ -1340,6 +1341,12 @@ func serveIMBootstrap(wrt http.ResponseWriter, req *http.Request) {
return
}
}
modulePayloads := workbenchModulePayloads(modules)
entryBadges, entries, err := bootstrapEntryPayloads(principal.TenantID, principal.Uid, modulePayloads)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, map[string]any{
"features": map[string]any{
"create_group": true,
@@ -1354,7 +1361,9 @@ func serveIMBootstrap(wrt http.ResponseWriter, req *http.Request) {
"user_search": true,
"scan_qr": true,
},
"modules": workbenchModulePayloads(modules),
"entry_badges": entryBadges,
"entries": entries,
"modules": modulePayloads,
"workbench_version": workbenchVersion,
"system_topic": map[string]any{
"topic": "sys",
@@ -3293,12 +3302,20 @@ func serveIMGroupCreate(wrt http.ResponseWriter, req *http.Request) {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
description := strings.TrimSpace(body.Description)
if len([]rune(description)) > 200 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "description"})
return
}
topicName := genTopicName()
now := time.Now().UTC().Round(time.Millisecond)
public := map[string]any{"name": name, "version": 1}
if avatar := strings.TrimSpace(body.AvatarFileID); avatar != "" {
public["avatar_file_id"] = avatar
}
if description != "" {
public["description"] = description
}
topic := &types.Topic{
ObjHeader: types.ObjHeader{Id: topicName},
TenantID: principal.TenantID,
@@ -3312,6 +3329,7 @@ func serveIMGroupCreate(wrt http.ResponseWriter, req *http.Request) {
OwnerUserID: principal.Uid,
Name: name,
AvatarFileID: strings.TrimSpace(body.AvatarFileID),
Description: description,
GroupStatus: types.GroupStatusActive,
MemberUploadFiles: true,
MemberPrivateChat: true,
@@ -5921,7 +5939,7 @@ func workbenchModulePayloads(modules []types.WorkbenchModule) []map[string]any {
"name": module.Name,
"icon_file_id": module.IconFileID,
"route": module.Route,
"unread_count": 0,
"unread_count": module.UnreadCount,
"enabled": module.Enabled,
"permission": module.Permission,
})
@@ -5929,6 +5947,77 @@ func workbenchModulePayloads(modules []types.WorkbenchModule) []map[string]any {
return items
}
func bootstrapEntryPayloads(tenantID types.TenantID, uid types.Uid, modules []map[string]any) (map[string]any, map[string]any, error) {
newFriendsPending := 0
if store.Relations != nil {
count, err := store.Relations.CountPendingFriendRequests(tenantID, uid)
if err != nil && !errors.Is(err, types.ErrNotFound) && !errors.Is(err, types.ErrUnsupported) {
return nil, nil, err
}
if err == nil {
newFriendsPending = count
}
}
systemUnread := 0
if store.Security != nil {
count, err := store.Security.CountUnreadSystemNotifications(tenantID, uid)
if err != nil && !errors.Is(err, types.ErrNotFound) && !errors.Is(err, types.ErrUnsupported) {
return nil, nil, err
}
if err == nil {
systemUnread = count
}
}
workbenchTotalUnread := 0
for _, module := range modules {
enabled, _ := module["enabled"].(bool)
if !enabled {
continue
}
workbenchTotalUnread += intFromAny(module["unread_count"])
}
entryBadges := map[string]any{
"contacts_total_unread": newFriendsPending,
"new_friends_pending_count": newFriendsPending,
"groups_unread_count": 0,
"file_assistant_unread_count": 0,
"system_unread_count": systemUnread,
"workbench_total_unread": workbenchTotalUnread,
}
entries := map[string]any{
"new_friends": map[string]any{
"enabled": true,
"route": "/contacts/new-friends",
"unread_count": newFriendsPending,
"unread_source": "business",
},
"groups": map[string]any{
"enabled": true,
"route": "/contacts/groups",
"unread_count": 0,
"unread_source": "business",
},
"file_assistant": map[string]any{
"enabled": true,
"route": "/chat/file-assistant",
"topic": "slf",
"unread_count": 0,
"unread_source": "topic",
},
"system_messages": map[string]any{
"enabled": true,
"route": "/chat/system",
"topic": "sys",
"unread_count": systemUnread,
"unread_source": "business",
},
}
return entryBadges, entries, nil
}
func organizationDepartmentPayloads(departments []types.OrganizationDepartment) []map[string]any {
items := make([]map[string]any, 0, len(departments))
for _, department := range departments {
+58 -8
View File
@@ -92,6 +92,9 @@ type testRelationsStore struct {
deleted bool
canSend *bool
reason string
pendingCount int
countTenant types.TenantID
countUser types.Uid
friends []types.FriendListItem
friendRequests []types.FriendRequestListItem
searchResults []types.UserSearchItem
@@ -794,6 +797,12 @@ func (s *testRelationsStore) ListFriendRequests(_ types.TenantID, _ types.Uid, _
return s.friendRequests, "", nil
}
func (s *testRelationsStore) CountPendingFriendRequests(tenantID types.TenantID, uid types.Uid) (int, error) {
s.countTenant = tenantID
s.countUser = uid
return s.pendingCount, nil
}
func (s *testRelationsStore) ResolveFriendRequest(_ types.TenantID, requestID string, actor types.Uid, status string, handledAt time.Time) (*types.FriendRequest, error) {
return &types.FriendRequest{
TenantID: 7,
@@ -1695,15 +1704,23 @@ func TestServeIMBootstrapIncludesHomeCapabilities(t *testing.T) {
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldSecurity := store.Security
oldWorkbench := store.Workbench
security := &testSecurityStore{realName: &types.RealNameVerification{Status: "verified"}}
oldRelations := store.Relations
security := &testSecurityStore{
realName: &types.RealNameVerification{Status: "verified"},
unreadNotifications: 4,
}
relations := &testRelationsStore{pendingCount: 2}
workbench := &testWorkbenchStore{version: 9, modules: []types.WorkbenchModule{
{Code: "organization", Name: "组织架构", Route: "/organization", Enabled: true, Permission: "enabled"},
{Code: "external", Name: "外链", Route: "https://example.com", Enabled: true, Permission: "enabled"},
{Code: "organization", Name: "组织架构", Route: "/organization", UnreadCount: 3, Enabled: true, Permission: "enabled"},
{Code: "meetings", Name: "会议通知", Route: "/meetings", UnreadCount: 5, Enabled: false, Permission: "enabled"},
{Code: "external", Name: "外链", Route: "https://example.com", UnreadCount: 9, Enabled: true, Permission: "enabled"},
}}
store.Security = security
store.Relations = relations
store.Workbench = workbench
defer func() {
store.Security = oldSecurity
store.Relations = oldRelations
store.Workbench = oldWorkbench
}()
@@ -1723,6 +1740,12 @@ func TestServeIMBootstrapIncludesHomeCapabilities(t *testing.T) {
if security.realNameTenant != 7 || security.realNameUser != 42 {
t.Fatalf("bootstrap realname lookup scope: tenant=%d uid=%s", security.realNameTenant, security.realNameUser)
}
if security.unreadTenant != 7 || security.unreadUser != 42 {
t.Fatalf("bootstrap system unread lookup scope: tenant=%d uid=%s", security.unreadTenant, security.unreadUser)
}
if relations.countTenant != 7 || relations.countUser != 42 {
t.Fatalf("bootstrap friend request count scope: tenant=%d uid=%s", relations.countTenant, relations.countUser)
}
if !workbench.calledOnce || workbench.tenantID != 7 || workbench.uid != 42 {
t.Fatalf("bootstrap workbench lookup scope: store=%#v", workbench)
}
@@ -1730,9 +1753,32 @@ func TestServeIMBootstrapIncludesHomeCapabilities(t *testing.T) {
t.Fatalf("unexpected bootstrap workbench version: %#v", data)
}
modules := data["modules"].([]any)
if len(modules) != 1 || modules[0].(map[string]any)["code"] != "organization" {
if len(modules) != 2 || modules[0].(map[string]any)["code"] != "organization" || modules[1].(map[string]any)["code"] != "meetings" {
t.Fatalf("unexpected bootstrap modules: %#v", modules)
}
if modules[0].(map[string]any)["unread_count"].(float64) != 3 {
t.Fatalf("unexpected bootstrap module unread: %#v", modules[0])
}
badges := data["entry_badges"].(map[string]any)
if badges["new_friends_pending_count"].(float64) != 2 ||
badges["contacts_total_unread"].(float64) != 2 ||
badges["system_unread_count"].(float64) != 4 ||
badges["workbench_total_unread"].(float64) != 3 {
t.Fatalf("unexpected bootstrap badges: %#v", badges)
}
entries := data["entries"].(map[string]any)
newFriends := entries["new_friends"].(map[string]any)
if newFriends["route"] != "/contacts/new-friends" || newFriends["unread_count"].(float64) != 2 || newFriends["unread_source"] != "business" {
t.Fatalf("unexpected new friends entry: %#v", newFriends)
}
systemMessages := entries["system_messages"].(map[string]any)
if systemMessages["topic"] != "sys" || systemMessages["unread_count"].(float64) != 4 || systemMessages["unread_source"] != "business" {
t.Fatalf("unexpected system messages entry: %#v", systemMessages)
}
fileAssistant := entries["file_assistant"].(map[string]any)
if fileAssistant["topic"] != "slf" || fileAssistant["unread_source"] != "topic" {
t.Fatalf("unexpected file assistant entry: %#v", fileAssistant)
}
realname := data["security"].(map[string]any)["realname"].(map[string]any)
if realname["status"] != "verified" {
t.Fatalf("unexpected bootstrap realname status: %#v", realname)
@@ -2747,7 +2793,8 @@ func TestServeIMGroupCreate(t *testing.T) {
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
store.Topics = topics
store.Subs = subs
store.GroupBusiness = &testGroupBusinessStore{}
groupBusiness := &testGroupBusinessStore{}
store.GroupBusiness = groupBusiness
defer func() {
store.Store = oldStore
store.Topics = oldTopics
@@ -2760,7 +2807,7 @@ func TestServeIMGroupCreate(t *testing.T) {
TenantID: 7,
Owner: types.Uid(42).String(),
SubCnt: 2,
Public: map[string]any{"name": "New Group", "avatar_file_id": "file-1", "version": 1},
Public: map[string]any{"name": "New Group", "avatar_file_id": "file-1", "description": "Project discussion", "version": 1},
}, nil)
subs.EXPECT().Get(types.TenantID(7), "grpnewgroup", types.Uid(42), false).Return(&types.Subscription{
TenantID: 7,
@@ -2770,7 +2817,7 @@ func TestServeIMGroupCreate(t *testing.T) {
ModeGiven: types.ModeCFull,
}, nil)
req := httptest.NewRequest(http.MethodPost, "/v1/im/groups", bytes.NewBufferString(`{"name":"New Group","member_user_ids":["usrAgAAAAAAAAA"],"avatar_file_id":"file-1"}`))
req := httptest.NewRequest(http.MethodPost, "/v1/im/groups", bytes.NewBufferString(`{"name":"New Group","description":"Project discussion","member_user_ids":["usrAgAAAAAAAAA"],"avatar_file_id":"file-1"}`))
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
@@ -2780,9 +2827,12 @@ func TestServeIMGroupCreate(t *testing.T) {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if data["topic"] != "grpnewgroup" || data["name"] != "New Group" {
if data["topic"] != "grpnewgroup" || data["name"] != "New Group" || data["description"] != "Project discussion" {
t.Fatalf("unexpected group create response: %#v", data)
}
if groupBusiness.settings == nil || groupBusiness.settings.Description != "Project discussion" {
t.Fatalf("group settings missing description: %#v", groupBusiness.settings)
}
}
func TestServeIMGroupDetailPatchSettingsProfileAndLeave(t *testing.T) {
+24
View File
@@ -2073,6 +2073,7 @@ func validUserProfilePatch(patch types.UserProfilePatch) bool {
type RelationsPersistenceInterface interface {
CreateFriendRequest(tenantID types.TenantID, req *types.FriendRequest) (*types.FriendRequest, error)
ListFriendRequests(tenantID types.TenantID, uid types.Uid, direction, status, cursor string, limit int) ([]types.FriendRequestListItem, string, error)
CountPendingFriendRequests(tenantID types.TenantID, uid types.Uid) (int, error)
ResolveFriendRequest(tenantID types.TenantID, requestID string, actor types.Uid, status string, handledAt time.Time) (*types.FriendRequest, error)
ListFriends(tenantID types.TenantID, uid types.Uid, query, initial, cursor string, limit int) ([]types.FriendListItem, string, error)
GetRelationshipContext(tenantID types.TenantID, userID, peerID types.Uid, topic string) (*types.RelationshipContext, error)
@@ -2182,6 +2183,17 @@ func (relationsMapper) ListFriendRequests(tenantID types.TenantID, uid types.Uid
return tenantAdp.TenantFriendRequestList(tenantID, uid, direction, status, cursor, limit)
}
func (relationsMapper) CountPendingFriendRequests(tenantID types.TenantID, uid types.Uid) (int, error) {
if uid.IsZero() {
return 0, types.ErrMalformed
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return 0, err
}
return tenantAdp.TenantFriendRequestPendingCount(tenantID, uid)
}
func (relationsMapper) ResolveFriendRequest(tenantID types.TenantID, requestID string, actor types.Uid, status string, handledAt time.Time) (*types.FriendRequest, error) {
if strings.TrimSpace(requestID) == "" || actor.IsZero() || (status != types.FriendRequestAccepted && status != types.FriendRequestRejected) {
return nil, types.ErrMalformed
@@ -3115,6 +3127,7 @@ type UserSecurityPersistenceInterface interface {
SetYouthMode(tenantID types.TenantID, uid types.Uid, enabled bool, policyVersion int, now time.Time) (*types.YouthModeSettings, error)
CreateSystemNotification(tenantID types.TenantID, notification *types.SystemNotification) error
ListSystemNotifications(tenantID types.TenantID, uid types.Uid, cursor string, limit int) ([]types.SystemNotification, string, error)
CountUnreadSystemNotifications(tenantID types.TenantID, uid types.Uid) (int, error)
MarkSystemNotificationRead(tenantID types.TenantID, uid types.Uid, eventID string, readAt time.Time) error
}
@@ -3245,6 +3258,17 @@ func (userSecurityMapper) ListSystemNotifications(tenantID types.TenantID, uid t
return tenantAdp.TenantSystemNotificationList(tenantID, uid, cursor, limit)
}
func (userSecurityMapper) CountUnreadSystemNotifications(tenantID types.TenantID, uid types.Uid) (int, error) {
if uid.IsZero() {
return 0, types.ErrMalformed
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return 0, err
}
return tenantAdp.TenantSystemNotificationUnreadCount(tenantID, uid)
}
func (userSecurityMapper) MarkSystemNotificationRead(tenantID types.TenantID, uid types.Uid, eventID string, readAt time.Time) error {
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
+12 -11
View File
@@ -3,15 +3,16 @@ package types
import "time"
type WorkbenchModule struct {
ID int64 `db:"id"`
Code string `db:"code"`
Name string `db:"name"`
IconFileID string `db:"icon_file_id"`
Route string `db:"route"`
SortOrder int `db:"sort_order"`
UnreadKey string `db:"unread_key"`
Enabled bool `db:"enabled"`
Permission string `db:"permission"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
ID int64 `db:"id"`
Code string `db:"code"`
Name string `db:"name"`
IconFileID string `db:"icon_file_id"`
Route string `db:"route"`
SortOrder int `db:"sort_order"`
UnreadKey string `db:"unread_key"`
UnreadCount int `db:"unread_count"`
Enabled bool `db:"enabled"`
Permission string `db:"permission"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
+18
View File
@@ -227,6 +227,7 @@ type testSecurityStore struct {
attempts int
lockedUntil *time.Time
notifications []types.SystemNotification
unreadNotifications int
readNotificationID string
realNameTenant types.TenantID
realNameUser types.Uid
@@ -235,6 +236,8 @@ type testSecurityStore struct {
listCursor string
listLimit int
nextCursor string
unreadTenant types.TenantID
unreadUser types.Uid
readTenant types.TenantID
readUser types.Uid
}
@@ -368,6 +371,21 @@ func (s *testSecurityStore) ListSystemNotifications(tenantID types.TenantID, uid
return s.notifications, s.nextCursor, s.err
}
func (s *testSecurityStore) CountUnreadSystemNotifications(tenantID types.TenantID, uid types.Uid) (int, error) {
s.unreadTenant = tenantID
s.unreadUser = uid
if s.unreadNotifications != 0 {
return s.unreadNotifications, s.err
}
count := 0
for _, notification := range s.notifications {
if notification.ReadAt == nil {
count++
}
}
return count, s.err
}
func (s *testSecurityStore) MarkSystemNotificationRead(tenantID types.TenantID, uid types.Uid, eventID string, readAt time.Time) error {
s.readTenant = tenantID
s.readUser = uid