feat: implement first batch im enhancements

This commit is contained in:
richjohnrambo
2026-08-21 11:42:45 +08:00
parent 14abe58b88
commit c89c466387
16 changed files with 2629 additions and 130 deletions
+8
View File
@@ -31,6 +31,10 @@ func (s testSecurityStore) CreateRealNameSubmission(types.TenantID, *types.RealN
return s.err
}
func (s testSecurityStore) CreateRealNameAppeal(types.TenantID, *types.RealNameAppeal) (*types.RealNameAppeal, error) {
return nil, s.err
}
func (s testSecurityStore) CreateActionToken(types.TenantID, *types.ActionToken) error {
return s.err
}
@@ -67,6 +71,10 @@ func (s testSecurityStore) ListSystemNotifications(types.TenantID, types.Uid, st
return nil, "", s.err
}
func (s testSecurityStore) CountUnreadSystemNotifications(types.TenantID, types.Uid) (int, error) {
return 0, s.err
}
func (s testSecurityStore) MarkSystemNotificationRead(types.TenantID, types.Uid, string, time.Time) error {
return s.err
}
+12
View File
@@ -321,6 +321,11 @@ type TenantBusinessAdapter interface {
TenantMessageGetDeleted(t.TenantID, string, t.Uid, *t.QueryOpt) ([]t.DelMessage, error)
TenantMessageSearch(t.TenantID, string, t.Uid, string, string, string, int) ([]t.Message, string, error)
TenantConversationSettingsGet(t.TenantID, t.Uid, string) (*t.ConversationSettings, error)
TenantConversationSettingsUpdate(t.TenantID, t.Uid, string, t.ConversationSettingsPatch) (*t.ConversationSettings, error)
TenantTopicDraftGet(t.TenantID, t.Uid, string) (*t.TopicDraft, error)
TenantTopicDraftPut(t.TenantID, t.Uid, string, string, []byte, int) (*t.TopicDraft, error)
TenantCallCreate(t.TenantID, *t.CallRecord) error
TenantCallAccept(t.TenantID, string, int, time.Time) error
TenantCallFinish(t.TenantID, string, int, string, int64, time.Time) error
@@ -334,6 +339,7 @@ type TenantBusinessAdapter interface {
TenantGroupMemberList(t.TenantID, string, t.GroupMemberListOptions) ([]t.GroupMember, string, error)
TenantGroupMemberUpdate(t.TenantID, string, t.Uid, t.GroupMemberPatch) (*t.GroupMember, error)
TenantGroupInvitationCreate(t.TenantID, *t.GroupInvitation) (*t.GroupInvitation, error)
TenantGroupInvitationAccept(t.TenantID, string, t.Uid, any, time.Time) (*t.GroupInvitation, error)
TenantGroupJoinRequestCreate(t.TenantID, *t.GroupJoinRequest) (*t.GroupJoinRequest, error)
TenantGroupJoinRequestList(t.TenantID, string, string, string, int) ([]t.GroupJoinRequest, string, error)
TenantGroupJoinRequestResolve(t.TenantID, string, string, t.Uid, string, string, int, any, time.Time) (*t.GroupJoinRequest, error)
@@ -343,6 +349,9 @@ type TenantBusinessAdapter interface {
TenantGroupMuteList(t.TenantID, string, string, int) ([]t.GroupMute, string, error)
TenantGroupMuteUpsert(t.TenantID, *t.GroupMute) (*t.GroupMute, error)
TenantGroupMuteDelete(t.TenantID, string, t.Uid, t.Uid, time.Time) error
TenantGroupFileList(t.TenantID, string, string, string, string, int) ([]t.GroupFile, string, error)
TenantGroupFileCreate(t.TenantID, *t.GroupFile) (*t.GroupFile, error)
TenantGroupFileDelete(t.TenantID, string, string, t.Uid, bool, time.Time) (*t.GroupFile, error)
TenantGroupCreate(t.TenantID, *t.Topic, t.Uid, any, *t.GroupSettings, []t.Uid, any, time.Time) error
TenantGroupInviteMember(t.TenantID, string, t.Uid, t.Uid, string, string, any, time.Time) (bool, error)
TenantGroupLeave(t.TenantID, string, t.Uid, time.Time) error
@@ -355,6 +364,7 @@ type TenantBusinessAdapter interface {
TenantWorkbenchModules(t.TenantID, t.Uid) ([]t.WorkbenchModule, int, error)
TenantOrganizationDepartments(t.TenantID, t.Uid, string, string, int) ([]t.OrganizationDepartment, string, error)
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)
TenantMeetingCreate(t.TenantID, t.Uid, *t.Meeting, []t.Uid) (*t.Meeting, error)
TenantMeetingGet(t.TenantID, t.Uid, string) (*t.Meeting, error)
@@ -374,6 +384,7 @@ type TenantBusinessAdapter interface {
TenantMonthlyReportGet(t.TenantID, t.Uid, string) (*t.MonthlyReport, error)
TenantMonthlyReportSaveDraft(t.TenantID, t.Uid, string, string, string, []string, int) (*t.MonthlyReport, error)
TenantMonthlyReportSubmit(t.TenantID, t.Uid, string, int) (*t.MonthlyReport, error)
TenantMonthlyReportReopen(t.TenantID, t.Uid, string, int) (*t.MonthlyReport, error)
TenantMonthlyReportStats(t.TenantID, t.Uid, string, string) (*t.MonthlyReportStats, error)
TenantMailboxConfigGet(t.TenantID, t.Uid) (*t.MailboxConfig, error)
TenantMailboxSubmissionCreate(t.TenantID, t.Uid, *t.MailboxSubmission, string) (*t.MailboxSubmission, error)
@@ -422,6 +433,7 @@ type TenantBusinessAdapter interface {
TenantUserSecurityBumpAuthVersion(t.TenantID, t.Uid, time.Time) (int, error)
TenantRealNameGet(t.TenantID, t.Uid) (*t.RealNameVerification, error)
TenantRealNameCreate(t.TenantID, *t.RealNameVerification) error
TenantRealNameAppealCreate(t.TenantID, *t.RealNameAppeal) (*t.RealNameAppeal, error)
TenantActionTokenCreate(t.TenantID, *t.ActionToken) error
TenantActionTokenConsume(t.TenantID, t.Uid, string, string, string, time.Time) (*t.ActionToken, error)
TenantIndependentPasswordGet(t.TenantID, t.Uid, string) (*t.IndependentPassword, error)
+18
View File
@@ -1595,6 +1595,24 @@ func createIMSecurityTables(tx *sql.Tx) error {
)`); err != nil {
return err
}
if _, err := tx.Exec(
`CREATE TABLE im_realname_appeals(
id BIGINT NOT NULL,
appeal_id VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
reason VARCHAR(500) NOT NULL DEFAULT '',
attachments_json JSON,
status VARCHAR(32) NOT NULL DEFAULT 'pending',
review_comment VARCHAR(500) NOT NULL DEFAULT '',
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_realname_appeals_appeal(appeal_id),
INDEX idx_im_realname_appeals_user(user_id, created_at),
INDEX idx_im_realname_appeals_status(status, created_at)
)`); err != nil {
return err
}
if _, err := tx.Exec(
`CREATE TABLE im_action_tokens(
id BIGINT NOT NULL AUTO_INCREMENT,
+27
View File
@@ -29,6 +29,33 @@ func ensureIMHTTPInfrastructureTables(exec sqlExecutor) error {
INDEX idx_im_business_events_outbox_pending(status, next_retry_at, id),
INDEX idx_im_business_events_outbox_target(target_type, target, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_realname_appeals (
id BIGINT NOT NULL, appeal_id VARCHAR(64) NOT NULL, user_id BIGINT NOT NULL,
reason VARCHAR(500) NOT NULL DEFAULT '', attachments_json JSON,
status VARCHAR(32) NOT NULL DEFAULT 'pending', review_comment VARCHAR(500) NOT NULL DEFAULT '',
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_realname_appeals_appeal(appeal_id),
INDEX idx_im_realname_appeals_user(user_id, created_at),
INDEX idx_im_realname_appeals_status(status, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_conversation_settings (
id BIGINT NOT NULL, user_id BIGINT NOT NULL, topic VARCHAR(128) NOT NULL,
pinned TINYINT NOT NULL DEFAULT 0, muted TINYINT NOT NULL DEFAULT 0,
remark VARCHAR(128) NOT NULL DEFAULT '', message_retention VARCHAR(32) NOT NULL DEFAULT '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_conversation_settings_user_topic(user_id, topic),
INDEX idx_im_conversation_settings_user_pinned(user_id, pinned, updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_topic_drafts (
id BIGINT NOT NULL, user_id BIGINT NOT NULL, topic VARCHAR(128) NOT NULL,
content TEXT, entities_json JSON, 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_topic_drafts_user_topic(user_id, topic),
INDEX idx_im_topic_drafts_user_updated(user_id, updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
}
for _, statement := range statements {
if _, err := exec.Exec(statement); err != nil {
+349
View File
@@ -2307,6 +2307,74 @@ func (a *adapter) TenantOrganizationMembers(id t.TenantID, uid t.Uid, department
return members, nextCursor, nil
}
func (a *adapter) TenantOrganizationMembersAll(id t.TenantID, uid t.Uid, departmentID, queryText, cursor string, limit int) ([]t.OrganizationMember, string, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, "", err
}
if tenantAdp != a {
return tenantAdp.TenantOrganizationMembersAll(id, uid, departmentID, queryText, cursor, limit)
}
}
if err := validTenant(id); err != nil || uid.IsZero() {
return nil, "", t.ErrMalformed
}
if limit <= 0 || limit > 100 {
limit = 50
}
where := []string{"m.employment_state='active'"}
args := []any{}
if strings.TrimSpace(departmentID) != "" {
where = append(where, "m.department_id=?")
args = append(args, strings.TrimSpace(departmentID))
}
if q := strings.TrimSpace(queryText); q != "" {
like := "%" + q + "%"
where = append(where, "(m.display_name LIKE ? OR m.mobile LIKE ? OR m.email LIKE ? OR CAST(m.user_id AS CHAR) LIKE ?)")
args = append(args, like, like, like, like)
}
if cursor != "" {
parsed, err := strconv.ParseInt(cursor, 10, 64)
if err != nil || parsed <= 0 {
return nil, "", t.ErrMalformed
}
where = append(where, "m.id>?")
args = append(args, parsed)
}
args = append(args, limit+1)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx, `SELECT m.id,m.department_id,m.user_id,m.display_name,m.position,m.avatar_file_id,m.mobile,m.email,
m.employment_state,m.contactable,m.sort_order,m.version,m.created_at,m.updated_at
FROM im_org_members AS m
WHERE `+strings.Join(where, " AND ")+`
ORDER BY m.sort_order ASC,m.id ASC LIMIT ?`, args...)
if err != nil {
return nil, "", err
}
defer rows.Close()
members := make([]t.OrganizationMember, 0, limit)
nextCursor := ""
for rows.Next() {
member, err := scanOrganizationMember(rows)
if err != nil {
return nil, "", err
}
if len(members) == limit {
nextCursor = strconv.FormatInt(member.ID, 10)
continue
}
members = append(members, *member)
}
if err = rows.Err(); err != nil {
return nil, "", err
}
return members, nextCursor, nil
}
func (a *adapter) TenantOrganizationSearch(id t.TenantID, uid t.Uid, queryText, resultTypes, cursor string, limit int) ([]t.OrganizationSearchItem, string, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
@@ -3547,6 +3615,37 @@ func (a *adapter) TenantMonthlyReportSubmit(id t.TenantID, uid t.Uid, reportID s
return a.TenantMonthlyReportGet(id, uid, reportID)
}
func (a *adapter) TenantMonthlyReportReopen(id t.TenantID, uid t.Uid, reportID string, version int) (*t.MonthlyReport, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantMonthlyReportReopen(id, uid, reportID, version)
}
}
if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(reportID) == "" || version <= 0 {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
res, err := a.db.ExecContext(ctx, `UPDATE im_monthly_reports
SET status=?,submitted_at=NULL,version=version+1,updated_at=CURRENT_TIMESTAMP(3)
WHERE report_id=? AND author_id=? AND status=? AND version=?`,
t.DailyReportStatusDraft, reportID, decodeUID(uid), t.DailyReportStatusSubmitted, version)
if err != nil {
return nil, err
}
affected, _ := res.RowsAffected()
if affected == 0 {
return nil, t.ErrInvalidResponse
}
return a.TenantMonthlyReportGet(id, uid, reportID)
}
func (a *adapter) TenantMonthlyReportStats(id t.TenantID, uid t.Uid, month, _ string) (*t.MonthlyReportStats, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
@@ -4627,6 +4726,213 @@ func kvMapFromJSON(value any) t.KVMap {
}
}
func (a *adapter) TenantConversationSettingsGet(id t.TenantID, uid t.Uid, topic string) (*t.ConversationSettings, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantConversationSettingsGet(id, uid, topic)
}
}
if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(topic) == "" {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
settings, err := a.conversationSettingsByKey(ctx, uid, strings.TrimSpace(topic))
if err == sql.ErrNoRows {
settingsID, idErr := nextSnowflakeID()
if idErr != nil {
return nil, idErr
}
now := t.TimeNow()
_, err = a.db.ExecContext(ctx, `INSERT IGNORE INTO im_conversation_settings
(id,user_id,topic,pinned,muted,remark,message_retention,version,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?)`,
settingsID, decodeUID(uid), strings.TrimSpace(topic), false, false, "", "default", 1, now, now)
if err != nil {
return nil, err
}
return a.conversationSettingsByKey(ctx, uid, strings.TrimSpace(topic))
}
if err != nil {
return nil, err
}
return settings, nil
}
func (a *adapter) TenantConversationSettingsUpdate(id t.TenantID, uid t.Uid, topic string, patch t.ConversationSettingsPatch) (*t.ConversationSettings, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantConversationSettingsUpdate(id, uid, topic, patch)
}
}
if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(topic) == "" || patch.Version <= 0 {
return nil, t.ErrMalformed
}
if _, err := a.TenantConversationSettingsGet(id, uid, topic); err != nil {
return nil, err
}
sets := []string{}
args := []any{}
if patch.Pinned != nil {
sets = append(sets, "pinned=?")
args = append(args, *patch.Pinned)
}
if patch.Muted != nil {
sets = append(sets, "muted=?")
args = append(args, *patch.Muted)
}
if patch.Remark != nil {
sets = append(sets, "remark=?")
args = append(args, strings.TrimSpace(*patch.Remark))
}
if patch.MessageRetention != nil {
sets = append(sets, "message_retention=?")
args = append(args, strings.TrimSpace(*patch.MessageRetention))
}
if len(sets) == 0 {
return a.TenantConversationSettingsGet(id, uid, topic)
}
sets = append(sets, "version=version+1", "updated_at=CURRENT_TIMESTAMP(3)")
args = append(args, decodeUID(uid), strings.TrimSpace(topic), patch.Version)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
res, err := a.db.ExecContext(ctx, `UPDATE im_conversation_settings SET `+strings.Join(sets, ",")+`
WHERE user_id=? AND topic=? AND version=?`, args...)
if err != nil {
return nil, err
}
affected, _ := res.RowsAffected()
if affected == 0 {
return nil, t.ErrInvalidResponse
}
return a.conversationSettingsByKey(ctx, uid, strings.TrimSpace(topic))
}
func (a *adapter) TenantTopicDraftGet(id t.TenantID, uid t.Uid, topic string) (*t.TopicDraft, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantTopicDraftGet(id, uid, topic)
}
}
if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(topic) == "" {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
draft, err := a.topicDraftByKey(ctx, uid, strings.TrimSpace(topic))
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
return draft, err
}
func (a *adapter) TenantTopicDraftPut(id t.TenantID, uid t.Uid, topic, content string, entities []byte, version int) (*t.TopicDraft, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantTopicDraftPut(id, uid, topic, content, entities, version)
}
}
if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(topic) == "" || version < 0 {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
if version == 0 {
draftID, err := nextSnowflakeID()
if err != nil {
return nil, err
}
now := t.TimeNow()
_, err = a.db.ExecContext(ctx, `INSERT INTO im_topic_drafts
(id,user_id,topic,content,entities_json,version,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?)
ON DUPLICATE KEY UPDATE content=VALUES(content),entities_json=VALUES(entities_json),
version=version+1,updated_at=VALUES(updated_at)`,
draftID, decodeUID(uid), strings.TrimSpace(topic), content, entities, 1, now, now)
if err != nil {
return nil, err
}
return a.topicDraftByKey(ctx, uid, strings.TrimSpace(topic))
}
res, err := a.db.ExecContext(ctx, `UPDATE im_topic_drafts
SET content=?,entities_json=?,version=version+1,updated_at=CURRENT_TIMESTAMP(3)
WHERE user_id=? AND topic=? AND version=?`,
content, entities, decodeUID(uid), strings.TrimSpace(topic), version)
if err != nil {
return nil, err
}
affected, _ := res.RowsAffected()
if affected == 0 {
return nil, t.ErrInvalidResponse
}
return a.topicDraftByKey(ctx, uid, strings.TrimSpace(topic))
}
func (a *adapter) conversationSettingsByKey(ctx context.Context, uid t.Uid, topic string) (*t.ConversationSettings, error) {
row := struct {
ID int64
UserID int64 `db:"user_id"`
Topic string `db:"topic"`
Pinned bool
Muted bool
Remark string
MessageRetention string `db:"message_retention"`
Version int `db:"version"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
if err := sqlx.GetContext(ctx, a.db, &row, `SELECT id,user_id,topic,pinned,muted,remark,message_retention,version,created_at,updated_at
FROM im_conversation_settings WHERE user_id=? AND topic=?`, decodeUID(uid), topic); err != nil {
return nil, err
}
return &t.ConversationSettings{ID: row.ID, UserID: store.EncodeUid(row.UserID), UserIDRaw: row.UserID, Topic: row.Topic,
Pinned: row.Pinned, Muted: row.Muted, Remark: row.Remark, MessageRetention: row.MessageRetention,
Version: row.Version, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}, nil
}
func (a *adapter) topicDraftByKey(ctx context.Context, uid t.Uid, topic string) (*t.TopicDraft, error) {
row := struct {
ID int64
UserID int64 `db:"user_id"`
Topic string `db:"topic"`
Content string
EntitiesJSON []byte `db:"entities_json"`
Version int `db:"version"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
if err := sqlx.GetContext(ctx, a.db, &row, `SELECT id,user_id,topic,content,entities_json,version,created_at,updated_at
FROM im_topic_drafts WHERE user_id=? AND topic=?`, decodeUID(uid), topic); err != nil {
return nil, err
}
return &t.TopicDraft{ID: row.ID, UserID: store.EncodeUid(row.UserID), UserIDRaw: row.UserID, Topic: row.Topic,
Content: row.Content, EntitiesJSON: row.EntitiesJSON, Version: row.Version, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}, nil
}
func (a *adapter) TenantFavoriteCreate(id t.TenantID, fav *t.FavoriteItem) (*t.FavoriteItem, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
@@ -5821,6 +6127,49 @@ func (a *adapter) TenantRealNameCreate(id t.TenantID, rv *t.RealNameVerification
return err
}
func (a *adapter) TenantRealNameAppealCreate(id t.TenantID, appeal *t.RealNameAppeal) (*t.RealNameAppeal, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantRealNameAppealCreate(id, appeal)
}
}
if err := validTenant(id); err != nil || appeal == nil || appeal.TenantID != id || appeal.UserID.IsZero() ||
strings.TrimSpace(appeal.AppealID) == "" || strings.TrimSpace(appeal.Reason) == "" {
return nil, t.ErrMalformed
}
appealID, err := nextSnowflakeID()
if err != nil {
return nil, err
}
now := t.TimeNow()
if appeal.Status == "" {
appeal.Status = "pending"
}
appeal.ID = appealID
appeal.CreatedAt = now
appeal.UpdatedAt = now
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
_, err = a.db.ExecContext(ctx, `INSERT INTO im_realname_appeals
(id,appeal_id,user_id,reason,attachments_json,status,review_comment,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?)`,
appeal.ID, appeal.AppealID, decodeUID(appeal.UserID), appeal.Reason, appeal.AttachmentsJSON,
appeal.Status, appeal.ReviewComment, appeal.CreatedAt, appeal.UpdatedAt)
if isDupe(err) {
return nil, t.ErrDuplicate
}
if err != nil {
return nil, err
}
return appeal, nil
}
func (a *adapter) TenantActionTokenCreate(id t.TenantID, token *t.ActionToken) error {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
+242
View File
@@ -398,6 +398,97 @@ func (a *adapter) TenantGroupInvitationCreate(id t.TenantID, inv *t.GroupInvitat
return inv, nil
}
func (a *adapter) TenantGroupInvitationAccept(id t.TenantID, invitationID string, uid t.Uid, memberPrivate any, now time.Time) (*t.GroupInvitation, error) {
if tenantAdp, err := a.routeGroupTenant(id); err != nil {
return nil, err
} else if tenantAdp != a {
return tenantAdp.TenantGroupInvitationAccept(id, invitationID, uid, memberPrivate, now)
}
if strings.TrimSpace(invitationID) == "" || uid.IsZero() || now.IsZero() {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
var inv t.GroupInvitation
var handledAt sql.NullTime
err = tx.QueryRowxContext(ctx, `SELECT id,invitation_id,topic,inviter_user_id,target_user_id,message,status,result,
join_request_id,idempotency_key,expires_at,handled_at,created_at,updated_at
FROM im_group_invitations WHERE invitation_id=? FOR UPDATE`, strings.TrimSpace(invitationID)).
Scan(&inv.ID, &inv.InvitationID, &inv.Topic, &inv.InviterUserRaw, &inv.TargetUserRaw, &inv.Message, &inv.Status,
&inv.Result, &inv.JoinRequestID, &inv.IdempotencyKey, &inv.ExpiresAt, &handledAt, &inv.CreatedAt, &inv.UpdatedAt)
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
if err != nil {
return nil, err
}
inv.InviterUserID = store.EncodeUid(inv.InviterUserRaw)
inv.TargetUserID = store.EncodeUid(inv.TargetUserRaw)
if handledAt.Valid {
inv.HandledAt = &handledAt.Time
}
if inv.TargetUserID != uid {
return nil, t.ErrPermissionDenied
}
if inv.ExpiresAt.Before(now) {
return nil, t.ErrExpired
}
if inv.Status == t.GroupInvitationStatusJoined || inv.Result == "joined" {
return &inv, nil
}
var deletedAt sql.NullTime
err = tx.QueryRowxContext(ctx, "SELECT DeletedAt FROM subscriptions WHERE Topic=? AND User=?", inv.Topic, uid.String()).Scan(&deletedAt)
if err != nil && err != sql.ErrNoRows {
return nil, err
}
if err == sql.ErrNoRows || deletedAt.Valid {
sub := &t.Subscription{TenantID: id, User: uid.String(), Topic: inv.Topic,
ModeWant: t.ModeCPublic, ModeGiven: t.ModeCPublic, Private: memberPrivate}
sub.InitTimes()
if err = createSubscription(tx, sub, deletedAt.Valid); err != nil {
return nil, err
}
}
member := &t.GroupMember{Topic: inv.Topic, UserID: uid, Role: t.GroupMemberRoleMember,
MemberStatus: t.GroupMemberStatusActive, JoinSource: "invitation", JoinedBy: inv.InviterUserID, JoinedAt: now,
Version: 1, CreatedAt: now, UpdatedAt: now}
if err = upsertGroupMemberTx(tx, member); err != nil {
return nil, err
}
if err = recountGroupMembersTx(tx, inv.Topic); err != nil {
return nil, err
}
if err = updateGroupTopicSubCountTx(tx, inv.Topic); err != nil {
return nil, err
}
if _, err = tx.ExecContext(ctx, `UPDATE im_group_invitations
SET status=?,result=?,handled_at=?,updated_at=? WHERE invitation_id=?`,
t.GroupInvitationStatusJoined, "joined", now, now, inv.InvitationID); err != nil {
return nil, err
}
if err = insertGroupEventTx(tx, inv.Topic, uid, "group.member.joined", map[string]any{
"user_id": uid.UserId(), "source": "invitation", "invitation_id": inv.InvitationID,
}); err != nil {
return nil, err
}
if err = tx.Commit(); err != nil {
return nil, err
}
inv.Status = t.GroupInvitationStatusJoined
inv.Result = "joined"
inv.HandledAt = &now
inv.UpdatedAt = now
return &inv, nil
}
func (a *adapter) TenantGroupJoinRequestCreate(id t.TenantID, req *t.GroupJoinRequest) (*t.GroupJoinRequest, error) {
if tenantAdp, err := a.routeGroupTenant(id); err != nil {
return nil, err
@@ -765,6 +856,130 @@ func (a *adapter) TenantGroupMuteDelete(id t.TenantID, topic string, uid, actor
return tx.Commit()
}
func (a *adapter) TenantGroupFileList(id t.TenantID, topic, fileType, query, cursor string, limit int) ([]t.GroupFile, string, error) {
if tenantAdp, err := a.routeGroupTenant(id); err != nil {
return nil, "", err
} else if tenantAdp != a {
return tenantAdp.TenantGroupFileList(id, topic, fileType, query, cursor, limit)
}
if strings.TrimSpace(topic) == "" {
return nil, "", t.ErrMalformed
}
where := []string{"topic=?", "status='active'"}
args := []any{strings.TrimSpace(topic)}
if fileType != "" {
where = append(where, "file_type=?")
args = append(args, strings.TrimSpace(fileType))
}
if query != "" {
where = append(where, "file_name LIKE ?")
args = append(args, "%"+strings.TrimSpace(query)+"%")
}
if cursor != "" {
parsed, err := strconv.ParseInt(cursor, 10, 64)
if err != nil || parsed <= 0 {
return nil, "", t.ErrMalformed
}
where = append(where, "id<?")
args = append(args, parsed)
}
args = append(args, limit+1)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx, `SELECT id,group_file_id,topic,file_id,source_topic,source_seq,uploader_id,
file_name,mime_type,file_type,file_size,status,deleted_by,deleted_at,idempotency_key,created_at,updated_at
FROM im_group_files WHERE `+strings.Join(where, " AND ")+` ORDER BY id DESC LIMIT ?`, args...)
if err != nil {
return nil, "", err
}
defer rows.Close()
items := make([]t.GroupFile, 0, limit)
next := ""
for rows.Next() {
file, err := scanGroupFile(rows)
if err != nil {
return nil, "", err
}
if len(items) == limit {
next = strconv.FormatInt(file.ID, 10)
continue
}
items = append(items, *file)
}
return items, next, rows.Err()
}
func (a *adapter) TenantGroupFileCreate(id t.TenantID, file *t.GroupFile) (*t.GroupFile, error) {
if tenantAdp, err := a.routeGroupTenant(id); err != nil {
return nil, err
} else if tenantAdp != a {
return tenantAdp.TenantGroupFileCreate(id, file)
}
if file == nil || strings.TrimSpace(file.Topic) == "" || strings.TrimSpace(file.FileID) == "" || file.UploaderID.IsZero() {
return nil, t.ErrMalformed
}
if file.GroupFileID == "" {
file.GroupFileID = "gfile_" + store.Store.GetUidString()
}
if file.FileType == "" {
file.FileType = "file"
}
if file.Status == "" {
file.Status = "active"
}
if file.IdempotencyKey == "" {
file.IdempotencyKey = file.GroupFileID
}
now := t.TimeNow()
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
_, err := a.db.ExecContext(ctx, `INSERT INTO im_group_files
(group_file_id,topic,file_id,source_topic,source_seq,uploader_id,file_name,mime_type,file_type,file_size,status,idempotency_key,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON DUPLICATE KEY UPDATE updated_at=updated_at`,
file.GroupFileID, file.Topic, file.FileID, file.SourceTopic, file.SourceSeq, decodeUID(file.UploaderID),
file.FileName, file.MIMEType, file.FileType, file.FileSize, file.Status, file.IdempotencyKey, now, now)
if err != nil && !isDupe(err) {
return nil, err
}
return a.groupFileByQuery(ctx, "SELECT * FROM im_group_files WHERE topic=? AND file_id=?", file.Topic, file.FileID)
}
func (a *adapter) TenantGroupFileDelete(id t.TenantID, topic, groupFileID string, actor t.Uid, canManage bool, now time.Time) (*t.GroupFile, error) {
if tenantAdp, err := a.routeGroupTenant(id); err != nil {
return nil, err
} else if tenantAdp != a {
return tenantAdp.TenantGroupFileDelete(id, topic, groupFileID, actor, canManage, now)
}
if strings.TrimSpace(topic) == "" || strings.TrimSpace(groupFileID) == "" || actor.IsZero() || now.IsZero() {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
query := `UPDATE im_group_files
SET status='deleted',deleted_by=?,deleted_at=?,updated_at=?
WHERE topic=? AND group_file_id=? AND status='active'`
args := []any{decodeUID(actor), now, now, strings.TrimSpace(topic), strings.TrimSpace(groupFileID)}
if !canManage {
query += " AND uploader_id=?"
args = append(args, decodeUID(actor))
}
res, err := a.db.ExecContext(ctx, query, args...)
if err != nil {
return nil, err
}
if affected, _ := res.RowsAffected(); affected == 0 {
return nil, t.ErrNotFound
}
return a.groupFileByQuery(ctx, "SELECT * FROM im_group_files WHERE topic=? AND group_file_id=?", strings.TrimSpace(topic), strings.TrimSpace(groupFileID))
}
func (a *adapter) TenantGroupCreate(id t.TenantID, topic *t.Topic, owner t.Uid, ownerPrivate any,
settings *t.GroupSettings, members []t.Uid, memberPrivate any, now time.Time) error {
if tenantAdp, err := a.routeGroupTenant(id); err != nil {
@@ -1230,6 +1445,21 @@ func (a *adapter) groupMuteByQuery(ctx context.Context, query string, args ...an
return &item, nil
}
func (a *adapter) groupFileByQuery(ctx context.Context, query string, args ...any) (*t.GroupFile, error) {
rows, err := a.db.QueryxContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
if rows.Next() {
return scanGroupFile(rows)
}
if err = rows.Err(); err != nil {
return nil, err
}
return nil, t.ErrNotFound
}
func (a *adapter) recountGroupMembers(ctx context.Context, topic string) error {
_, err := a.db.ExecContext(ctx, "UPDATE im_group_settings SET member_count=(SELECT COUNT(*) FROM im_group_members WHERE topic=? AND member_status=?),updated_at=CURRENT_TIMESTAMP(3) WHERE topic=?", topic, t.GroupMemberStatusActive, topic)
return err
@@ -1270,3 +1500,15 @@ func scanGroupMute(item *t.GroupMute) {
item.RevokedBy = store.EncodeUid(*item.RevokedRaw)
}
}
func scanGroupFile(rows *sqlx.Rows) (*t.GroupFile, error) {
var item t.GroupFile
if err := rows.StructScan(&item); err != nil {
return nil, err
}
item.UploaderID = store.EncodeUid(item.UploaderRaw)
if item.DeletedByRaw != nil {
item.DeletedBy = store.EncodeUid(*item.DeletedByRaw)
}
return &item, nil
}
+513 -20
View File
@@ -270,6 +270,11 @@ type imRealNameSubmissionRequest struct {
IDNumber string `json:"id_number"`
}
type imRealNameAppealRequest struct {
Reason string `json:"reason"`
Attachments []string `json:"attachments,omitempty"`
}
type imIndependentPasswordRequest struct {
Password string `json:"password"`
Purpose string `json:"purpose"`
@@ -294,6 +299,25 @@ type imDevicePushTokenRequest struct {
Platform string `json:"platform,omitempty"`
}
type imP2PConversationRequest struct {
PeerUserID string `json:"peer_user_id"`
}
type imConversationSettingsRequest struct {
Pinned *bool `json:"pinned,omitempty"`
Muted *bool `json:"muted,omitempty"`
MuteNotifications *bool `json:"mute_notifications,omitempty"`
Remark *string `json:"remark,omitempty"`
MessageRetention *string `json:"message_retention,omitempty"`
Version int `json:"version"`
}
type imTopicDraftRequest struct {
Content string `json:"content"`
Entities any `json:"entities,omitempty"`
Version int `json:"version"`
}
type imRiskChallengeConfirmRequest struct {
Decision string `json:"decision"`
}
@@ -426,6 +450,16 @@ type imGroupJoinRequestActionRequest struct {
Version int `json:"version,omitempty"`
}
type imGroupFileCreateRequest struct {
FileID string `json:"file_id"`
FileName string `json:"file_name,omitempty"`
MIMEType string `json:"mime_type,omitempty"`
FileType string `json:"file_type,omitempty"`
FileSize int64 `json:"file_size,omitempty"`
SourceTopic string `json:"source_topic,omitempty"`
SourceSeq int `json:"source_seq,omitempty"`
}
type imGroupAnnouncementPutRequest struct {
Content string `json:"content"`
Version int `json:"version,omitempty"`
@@ -1306,6 +1340,58 @@ func serveIMRealNameSubmissions(wrt http.ResponseWriter, req *http.Request) {
writeIMSuccess(wrt, req, map[string]any{"submission_id": submissionID, "status": "pending"})
}
func serveIMRealNameAppeals(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
}
var body imRealNameAppealRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
reason := strings.TrimSpace(body.Reason)
if reason == "" || len([]rune(reason)) > 500 || len(body.Attachments) > 9 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "reason"})
return
}
attachments := make([]string, 0, len(body.Attachments))
for _, raw := range body.Attachments {
fileID := strings.TrimSpace(raw)
if fileID == "" {
continue
}
attachments = append(attachments, fileID)
}
encoded, err := json.Marshal(attachments)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
token, err := randomIMToken(12)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
appeal, err := store.Security.CreateRealNameAppeal(principal.TenantID, &types.RealNameAppeal{
TenantID: principal.TenantID,
UserID: principal.Uid,
AppealID: "rna_" + token,
Reason: reason,
Attachments: attachments,
AttachmentsJSON: encoded,
Status: "pending",
})
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, realNameAppealPayload(appeal))
}
func serveIMBootstrap(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
@@ -1438,6 +1524,7 @@ func serveIMOrganizationDepartments(wrt http.ResponseWriter, req *http.Request)
writeIMSuccess(wrt, req, map[string]any{
"items": organizationDepartmentPayloads(items),
"next_cursor": nextCursor,
"has_more": nextCursor != "",
})
}
@@ -1465,6 +1552,35 @@ func serveIMOrganizationDepartmentResource(wrt http.ResponseWriter, req *http.Re
"department_id": departmentID,
"items": organizationMemberPayloads(items),
"next_cursor": nextCursor,
"has_more": nextCursor != "",
})
}
func serveIMOrganizationMembers(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
}
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
query := strings.TrimSpace(req.URL.Query().Get("q"))
if query == "" {
query = strings.TrimSpace(req.URL.Query().Get("keyword"))
}
departmentID := strings.TrimSpace(req.URL.Query().Get("department_id"))
items, nextCursor, err := store.Organization.ListAllMembers(principal.TenantID, principal.Uid, departmentID, query, strings.TrimSpace(req.URL.Query().Get("cursor")), limit)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, map[string]any{
"department_id": departmentID,
"items": organizationMemberPayloads(items),
"next_cursor": nextCursor,
"has_more": nextCursor != "",
})
}
@@ -1491,6 +1607,7 @@ func serveIMOrganizationSearch(wrt http.ResponseWriter, req *http.Request) {
writeIMSuccess(wrt, req, map[string]any{
"items": organizationSearchPayloads(items),
"next_cursor": nextCursor,
"has_more": nextCursor != "",
})
}
@@ -1981,7 +2098,7 @@ func serveIMMonthlyReports(wrt http.ResponseWriter, req *http.Request) {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, map[string]any{"items": monthlyReportListPayloads(reports), "next_cursor": nextCursor})
writeIMSuccess(wrt, req, map[string]any{"items": monthlyReportListPayloads(reports), "next_cursor": nextCursor, "has_more": nextCursor != ""})
case http.MethodPost:
var body struct {
Month string `json:"month"`
@@ -2031,6 +2148,9 @@ func serveIMMonthlyReportResource(wrt http.ResponseWriter, req *http.Request) {
case "submit":
serveIMMonthlyReportSubmit(wrt, req, reportID)
return
case "reopen":
serveIMMonthlyReportReopen(wrt, req, reportID)
return
}
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
}
@@ -2097,6 +2217,29 @@ func serveIMMonthlyReportSubmit(wrt http.ResponseWriter, req *http.Request, repo
writeIMSuccess(wrt, req, monthlyReportDetailPayload(report, principal.Uid))
}
func serveIMMonthlyReportReopen(wrt http.ResponseWriter, req *http.Request, reportID string) {
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
}
var body struct {
Version int `json:"version"`
}
if !decodeIMJSONBody(wrt, req, &body) {
return
}
report, err := store.MonthlyReports.Reopen(principal.TenantID, principal.Uid, reportID, body.Version)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, monthlyReportDetailPayload(report, principal.Uid))
}
func serveIMMonthlyReportStatistics(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
@@ -2488,6 +2631,37 @@ func serveIMConversationSearch(wrt http.ResponseWriter, req *http.Request) {
writeIMSuccess(wrt, req, map[string]any{"items": items, "next_cursor": nextCursor})
}
func serveIMP2PConversation(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
}
var body imP2PConversationRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
peer := types.ParseUserId(strings.TrimSpace(body.PeerUserID))
if peer.IsZero() || peer == principal.Uid {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "peer_user_id"})
return
}
topic, err := store.Conversations.EnsureP2P(principal.TenantID, principal.Uid, peer)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
ctx, err := store.Relations.GetRelationshipContext(principal.TenantID, principal.Uid, peer, topic)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, relationshipContextPayload(ctx))
}
func serveIMForwardTargets(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
@@ -2828,6 +3002,8 @@ func serveIMFavoriteForward(wrt http.ResponseWriter, req *http.Request) {
func serveIMTopicMessageAction(wrt http.ResponseWriter, req *http.Request) {
switch {
case strings.HasSuffix(req.URL.Path, "/draft"):
serveIMTopicDraft(wrt, req)
case strings.HasSuffix(req.URL.Path, "/calls"):
serveIMTopicCalls(wrt, req)
case strings.HasSuffix(req.URL.Path, "/messages/search"):
@@ -2847,6 +3023,53 @@ func serveIMTopicMessageAction(wrt http.ResponseWriter, req *http.Request) {
}
}
func serveIMTopicDraft(wrt http.ResponseWriter, req *http.Request) {
topic := topicFromDraftPath(req.URL.Path)
if topic == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
switch req.Method {
case http.MethodGet:
draft, err := store.Conversations.GetDraft(principal.TenantID, principal.Uid, topic)
if err != nil {
if errors.Is(err, types.ErrNotFound) {
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "content": "", "entities": []any{}, "version": 0})
return
}
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, topicDraftPayload(draft))
case http.MethodPut:
var body imTopicDraftRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
encoded, err := json.Marshal(body.Entities)
if err != nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "entities"})
return
}
if int64(len([]byte(body.Content))) > globals.maxMessageSize {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "content"})
return
}
draft, err := store.Conversations.PutDraft(principal.TenantID, principal.Uid, topic, body.Content, encoded, body.Version)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, topicDraftPayload(draft))
default:
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
}
}
func serveIMCalls(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
@@ -3149,44 +3372,35 @@ func serveIMMessageRevision(wrt http.ResponseWriter, req *http.Request) {
}
func serveIMConversationContext(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
}
topic, tail := conversationResourceFromPath(req.URL.Path)
if tail == "settings" {
if topic == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
return
}
writeIMSuccess(wrt, req, map[string]any{
"topic": topic,
"pinned": false,
"mute_notifications": false,
"message_retention": "default",
"blocked": false,
"remark": "",
"version": 1,
"permissions": map[string]any{"can_update": false},
})
serveIMConversationSettings(wrt, req, principal, topic)
return
}
if tail == "messages" {
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
if topic == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
return
}
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": []any{}, "next_cursor": ""})
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": []any{}, "next_cursor": "", "has_more": false})
return
}
if tail != "context" {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "conversation route not found", nil)
return
}
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
peer := peerFromP2PTopic(topic, principal.Uid)
if topic == "" || peer.IsZero() {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
@@ -3200,6 +3414,46 @@ func serveIMConversationContext(wrt http.ResponseWriter, req *http.Request) {
writeIMSuccess(wrt, req, relationshipContextPayload(ctx))
}
func serveIMConversationSettings(wrt http.ResponseWriter, req *http.Request, principal *imPrincipal, topic string) {
if topic == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
return
}
switch req.Method {
case http.MethodGet:
settings, err := store.Conversations.GetSettings(principal.TenantID, principal.Uid, topic)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, conversationSettingsPayload(settings))
case http.MethodPatch:
var body imConversationSettingsRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
muted := body.Muted
if muted == nil {
muted = body.MuteNotifications
}
patch := types.ConversationSettingsPatch{
Pinned: body.Pinned,
Muted: muted,
Remark: body.Remark,
MessageRetention: body.MessageRetention,
Version: body.Version,
}
settings, err := store.Conversations.UpdateSettings(principal.TenantID, principal.Uid, topic, patch)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, conversationSettingsPayload(settings))
default:
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
}
}
func serveIMFriendRequests(wrt http.ResponseWriter, req *http.Request) {
switch req.Method {
case http.MethodGet:
@@ -3510,6 +3764,10 @@ func serveIMGroupResource(wrt http.ResponseWriter, req *http.Request) {
serveIMGroupAnnouncements(wrt, req, topic, tail)
return
}
if tail == "files" || strings.HasPrefix(tail, "files/") {
serveIMGroupFiles(wrt, req, topic, tail)
return
}
if tail == "governance" {
serveIMGroupGovernance(wrt, req, topic)
return
@@ -3967,6 +4225,104 @@ func serveIMGroupInvitations(wrt http.ResponseWriter, req *http.Request, topic s
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": results})
}
func serveIMGroupFiles(wrt http.ResponseWriter, req *http.Request, topic, tail string) {
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
if !ok {
return
}
access := selfSub.ModeGiven & selfSub.ModeWant
switch {
case tail == "files" && req.Method == http.MethodGet:
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
items, nextCursor, err := store.GroupBusiness.ListFiles(principal.TenantID, topic,
strings.TrimSpace(req.URL.Query().Get("type")),
strings.TrimSpace(req.URL.Query().Get("q")),
strings.TrimSpace(req.URL.Query().Get("cursor")),
limit)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, map[string]any{
"topic": topic,
"items": groupFilePayloads(items),
"next_cursor": nextCursor,
"has_more": nextCursor != "",
})
case tail == "files" && req.Method == http.MethodPost:
idempotencyKey, ok := requireIMIdempotencyKey(wrt, req)
if !ok {
return
}
var body imGroupFileCreateRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
fileID := strings.TrimSpace(body.FileID)
if fileID == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "file_id"})
return
}
file, err := store.GroupBusiness.CreateFile(principal.TenantID, &types.GroupFile{
Topic: topic,
FileID: fileID,
SourceTopic: strings.TrimSpace(body.SourceTopic),
SourceSeq: body.SourceSeq,
UploaderID: principal.Uid,
FileName: strings.TrimSpace(body.FileName),
MIMEType: strings.TrimSpace(body.MIMEType),
FileType: normalizeIMFileType(body.FileType),
FileSize: body.FileSize,
Status: "active",
IdempotencyKey: idempotencyKey,
})
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, groupFilePayload(file))
case strings.HasPrefix(tail, "files/") && req.Method == http.MethodDelete:
groupFileID := strings.TrimSpace(strings.TrimPrefix(tail, "files/"))
if groupFileID == "" || strings.Contains(groupFileID, "/") {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "file_id"})
return
}
canManage := access.IsAdmin() || access.IsOwner()
file, err := store.GroupBusiness.DeleteFile(principal.TenantID, topic, groupFileID, principal.Uid, canManage, time.Now().UTC().Round(time.Millisecond))
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, groupFilePayload(file))
default:
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
}
}
func serveIMGroupInvitationResource(wrt http.ResponseWriter, req *http.Request) {
invitationID, tail, ok := groupInvitationResourcePath(req.URL.Path)
if !ok || tail != "accept" {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group invitation route not found", nil)
return
}
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
}
now := time.Now().UTC().Round(time.Millisecond)
inv, err := store.GroupBusiness.AcceptInvitation(principal.TenantID, invitationID, principal.Uid, groupMemberPrivate("", false, false, 1, now), now)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
dispatchIMGroupEvents(principal.TenantID)
writeIMSuccess(wrt, req, groupInvitationPayload(inv))
}
func serveIMGroupJoinRequests(wrt http.ResponseWriter, req *http.Request, topic string) {
if req.Method == http.MethodPost {
principal, ok := authenticateIMHTTPBearer(wrt, req)
@@ -5332,6 +5688,89 @@ func realNamePayload(rv *types.RealNameVerification) map[string]any {
return data
}
func realNameAppealPayload(appeal *types.RealNameAppeal) map[string]any {
if appeal == nil {
return map[string]any{}
}
attachments := appeal.Attachments
if len(attachments) == 0 && len(appeal.AttachmentsJSON) > 0 {
_ = json.Unmarshal(appeal.AttachmentsJSON, &attachments)
}
return map[string]any{
"appeal_id": appeal.AppealID,
"reason": appeal.Reason,
"attachments": attachments,
"status": appeal.Status,
"created_at": appeal.CreatedAt.Format(time.RFC3339Nano),
}
}
func groupInvitationPayload(inv *types.GroupInvitation) map[string]any {
if inv == nil {
return map[string]any{}
}
data := map[string]any{
"invitation_id": inv.InvitationID,
"topic": inv.Topic,
"inviter_id": inv.InviterUserID.UserId(),
"target_user_id": inv.TargetUserID.UserId(),
"status": inv.Status,
"result": inv.Result,
"expires_at": inv.ExpiresAt.Format(time.RFC3339Nano),
}
if inv.HandledAt != nil {
data["handled_at"] = inv.HandledAt.Format(time.RFC3339Nano)
}
return data
}
func groupFilePayloads(files []types.GroupFile) []map[string]any {
items := make([]map[string]any, 0, len(files))
for i := range files {
items = append(items, groupFilePayload(&files[i]))
}
return items
}
func groupFilePayload(file *types.GroupFile) map[string]any {
if file == nil {
return map[string]any{}
}
data := map[string]any{
"group_file_id": file.GroupFileID,
"topic": file.Topic,
"file_id": file.FileID,
"file_name": file.FileName,
"mime_type": file.MIMEType,
"file_type": file.FileType,
"file_size": file.FileSize,
"uploader_id": file.UploaderID.UserId(),
"status": file.Status,
"created_at": file.CreatedAt.UTC().Format(time.RFC3339Nano),
"updated_at": file.UpdatedAt.UTC().Format(time.RFC3339Nano),
}
if file.SourceTopic != "" {
data["source_topic"] = file.SourceTopic
}
if file.SourceSeq > 0 {
data["source_seq"] = file.SourceSeq
}
if file.DeletedAt != nil {
data["deleted_at"] = file.DeletedAt.UTC().Format(time.RFC3339Nano)
data["deleted_by"] = file.DeletedBy.UserId()
}
return data
}
func normalizeIMFileType(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "image", "video", "audio", "document", "file":
return strings.ToLower(strings.TrimSpace(raw))
default:
return "file"
}
}
func parseIMLimit(raw string, fallback, max int) int {
limit := fallback
if raw != "" {
@@ -5442,6 +5881,40 @@ func conversationPayload(sub types.Subscription) map[string]any {
}
}
func conversationSettingsPayload(settings *types.ConversationSettings) map[string]any {
if settings == nil {
return map[string]any{}
}
return map[string]any{
"topic": settings.Topic,
"pinned": settings.Pinned,
"muted": settings.Muted,
"mute_notifications": settings.Muted,
"remark": settings.Remark,
"message_retention": settings.MessageRetention,
"version": settings.Version,
"updated_at": settings.UpdatedAt.UTC().Format(time.RFC3339Nano),
"permissions": map[string]any{"can_update": true},
}
}
func topicDraftPayload(draft *types.TopicDraft) map[string]any {
if draft == nil {
return map[string]any{}
}
var entities any = []any{}
if len(draft.EntitiesJSON) > 0 {
_ = json.Unmarshal(draft.EntitiesJSON, &entities)
}
return map[string]any{
"topic": draft.Topic,
"content": draft.Content,
"entities": entities,
"version": draft.Version,
"updated_at": draft.UpdatedAt.UTC().Format(time.RFC3339Nano),
}
}
func forwardTargetPayload(sub types.Subscription) map[string]any {
topicType := ""
switch types.GetTopicCat(sub.Topic) {
@@ -7691,6 +8164,20 @@ func systemNotificationIDFromPath(path string) string {
return strings.TrimSpace(eventID)
}
func groupInvitationResourcePath(path string) (string, string, bool) {
const marker = "/v1/im/group-invitations/"
idx := strings.Index(path, marker)
if idx < 0 {
return "", "", false
}
rest := strings.TrimPrefix(path[idx:], marker)
invitationID, tail, ok := strings.Cut(rest, "/")
if !ok || strings.TrimSpace(invitationID) == "" {
return "", "", false
}
return strings.TrimSpace(invitationID), strings.Trim(tail, "/"), true
}
func messageReceiptsPath(path string) (string, int, bool) {
const marker = "/v1/im/topics/"
idx := strings.Index(path, marker)
@@ -7731,6 +8218,12 @@ func topicFromCallsPath(path string) string {
return topicFromPathWithSuffix(path, prefix, suffix)
}
func topicFromDraftPath(path string) string {
const prefix = "/v1/im/topics/"
const suffix = "/draft"
return topicFromPathWithSuffix(path, prefix, suffix)
}
func topicFromPathWithSuffix(path, prefix, suffix string) string {
idx := strings.Index(path, prefix)
if idx < 0 || !strings.HasSuffix(path, suffix) {
+384
View File
@@ -121,6 +121,14 @@ type testCallsStore struct {
next string
}
type testConversationsStore struct {
topic string
settings *types.ConversationSettings
settingsPatch types.ConversationSettingsPatch
draft *types.TopicDraft
putDraftCalled bool
}
type testWorkbenchStore struct {
modules []types.WorkbenchModule
version int
@@ -199,6 +207,7 @@ type testGroupBusinessStore struct {
joinRequests []types.GroupJoinRequest
announcement *types.GroupAnnouncement
mutes []types.GroupMute
files []types.GroupFile
invitations []types.GroupInvitation
updatedSettings types.GroupSettingsPatch
updatedMembers []types.GroupMemberPatch
@@ -265,6 +274,19 @@ func (s *testGroupBusinessStore) CreateInvitation(_ types.TenantID, inv *types.G
return inv, nil
}
func (s *testGroupBusinessStore) AcceptInvitation(_ types.TenantID, invitationID string, uid types.Uid, _ any, now time.Time) (*types.GroupInvitation, error) {
for i := range s.invitations {
if s.invitations[i].InvitationID == invitationID {
s.invitations[i].TargetUserID = uid
s.invitations[i].Status = types.GroupInvitationStatusJoined
s.invitations[i].Result = "joined"
s.invitations[i].HandledAt = &now
return &s.invitations[i], nil
}
}
return &types.GroupInvitation{InvitationID: invitationID, Topic: "grp_test", TargetUserID: uid, Status: types.GroupInvitationStatusJoined, Result: "joined", ExpiresAt: now.Add(time.Hour), HandledAt: &now}, nil
}
func (s *testGroupBusinessStore) CreateJoinRequest(_ types.TenantID, req *types.GroupJoinRequest) (*types.GroupJoinRequest, error) {
now := time.Now().UTC()
req.CreatedAt = now
@@ -328,6 +350,33 @@ func (s *testGroupBusinessStore) DeleteMute(_ types.TenantID, _ string, _ types.
return nil
}
func (s *testGroupBusinessStore) ListFiles(_ types.TenantID, _ string, _ string, _ string, _ string, _ int) ([]types.GroupFile, string, error) {
return s.files, "", nil
}
func (s *testGroupBusinessStore) CreateFile(_ types.TenantID, file *types.GroupFile) (*types.GroupFile, error) {
if file.GroupFileID == "" {
file.GroupFileID = "gfile_test"
}
now := time.Now().UTC().Round(time.Millisecond)
file.CreatedAt = now
file.UpdatedAt = now
s.files = append(s.files, *file)
return file, nil
}
func (s *testGroupBusinessStore) DeleteFile(_ types.TenantID, topic, groupFileID string, actor types.Uid, _ bool, now time.Time) (*types.GroupFile, error) {
for i := range s.files {
if s.files[i].Topic == topic && s.files[i].GroupFileID == groupFileID {
s.files[i].Status = "deleted"
s.files[i].DeletedBy = actor
s.files[i].DeletedAt = &now
return &s.files[i], nil
}
}
return &types.GroupFile{Topic: topic, GroupFileID: groupFileID, Status: "deleted", DeletedBy: actor, DeletedAt: &now, CreatedAt: now, UpdatedAt: now}, nil
}
func (s *testGroupBusinessStore) InviteMember(_ types.TenantID, topic string, inviter, target types.Uid,
message, idempotencyKey string, _ any, now time.Time) (bool, error) {
for _, invitation := range s.invitations {
@@ -410,6 +459,12 @@ func (s *testOrganizationStore) ListMembers(_ types.TenantID, _ types.Uid, depar
return s.members, "", nil
}
func (s *testOrganizationStore) ListAllMembers(_ types.TenantID, _ types.Uid, departmentID, queryText, _ string, _ int) ([]types.OrganizationMember, string, error) {
s.departmentID = departmentID
s.query = queryText
return s.members, "", nil
}
func (s *testOrganizationStore) Search(_ types.TenantID, _ types.Uid, queryText, resultTypes, _ string, _ int) ([]types.OrganizationSearchItem, string, error) {
s.query = queryText
s.resultTypes = resultTypes
@@ -560,6 +615,13 @@ func (s *testMonthlyReportsStore) Submit(_ types.TenantID, _ types.Uid, _ string
return s.report, nil
}
func (s *testMonthlyReportsStore) Reopen(_ types.TenantID, _ types.Uid, _ string, version int) (*types.MonthlyReport, error) {
s.report.Status = types.DailyReportStatusDraft
s.report.SubmittedAt = nil
s.report.Version = version + 1
return s.report, nil
}
func (s *testMonthlyReportsStore) Stats(_ types.TenantID, _ types.Uid, _ string, _ string) (*types.MonthlyReportStats, error) {
return &types.MonthlyReportStats{ExpectedCount: 4, SubmittedCount: 2, MissingCount: 2}, nil
}
@@ -755,6 +817,54 @@ func (s *testCallsStore) List(tenantID types.TenantID, uid types.Uid, topic, cur
return s.items, s.next, nil
}
func (s *testConversationsStore) EnsureP2P(_ types.TenantID, uid, peer types.Uid) (string, error) {
s.topic = uid.P2PName(peer)
return s.topic, nil
}
func (s *testConversationsStore) GetSettings(_ types.TenantID, _ types.Uid, topic string) (*types.ConversationSettings, error) {
if s.settings == nil {
now := time.Now().UTC().Round(time.Millisecond)
s.settings = &types.ConversationSettings{Topic: topic, Pinned: false, Muted: false, MessageRetention: "default", Version: 1, CreatedAt: now, UpdatedAt: now}
}
return s.settings, nil
}
func (s *testConversationsStore) UpdateSettings(_ types.TenantID, _ types.Uid, topic string, patch types.ConversationSettingsPatch) (*types.ConversationSettings, error) {
s.settingsPatch = patch
settings, _ := s.GetSettings(0, 0, topic)
if patch.Pinned != nil {
settings.Pinned = *patch.Pinned
}
if patch.Muted != nil {
settings.Muted = *patch.Muted
}
if patch.Remark != nil {
settings.Remark = *patch.Remark
}
if patch.MessageRetention != nil {
settings.MessageRetention = *patch.MessageRetention
}
settings.Version = patch.Version + 1
settings.UpdatedAt = time.Now().UTC().Round(time.Millisecond)
return settings, nil
}
func (s *testConversationsStore) GetDraft(_ types.TenantID, _ types.Uid, topic string) (*types.TopicDraft, error) {
if s.draft == nil {
return nil, types.ErrNotFound
}
s.draft.Topic = topic
return s.draft, nil
}
func (s *testConversationsStore) PutDraft(_ types.TenantID, uid types.Uid, topic, content string, entities []byte, version int) (*types.TopicDraft, error) {
s.putDraftCalled = true
now := time.Now().UTC().Round(time.Millisecond)
s.draft = &types.TopicDraft{UserID: uid, Topic: topic, Content: content, EntitiesJSON: entities, Version: version + 1, CreatedAt: now, UpdatedAt: now}
return s.draft, nil
}
func (s *testFavoritesStore) Create(_ types.TenantID, item *types.FavoriteItem) (*types.FavoriteItem, error) {
for i := range s.items {
existing := &s.items[i]
@@ -1614,6 +1724,34 @@ func TestServeIMRealNameSubmissionCreatesPending(t *testing.T) {
}
}
func TestServeIMRealNameAppealCreatesPending(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldSecurity := store.Security
security := &testSecurityStore{}
store.Security = security
defer func() { store.Security = oldSecurity }()
req := httptest.NewRequest(http.MethodPost, "/v1/im/security/realname/appeals",
bytes.NewBufferString(`{"reason":"证件信息需要更正","attachments":["file1"]}`))
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMRealNameAppeals(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if security.realNameAppeal == nil || security.realNameAppeal.Reason != "证件信息需要更正" {
t.Fatalf("appeal was not stored: %#v", security.realNameAppeal)
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if data["status"] != "pending" || data["appeal_id"] == "" {
t.Fatalf("unexpected appeal response: %#v", data)
}
}
func TestServeIMIndependentPasswordSetAndVerify(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
@@ -1889,6 +2027,32 @@ func TestServeIMOrganizationDepartmentMembers(t *testing.T) {
}
}
func TestServeIMOrganizationMembers(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldOrganization := store.Organization
org := &testOrganizationStore{members: []types.OrganizationMember{
{DepartmentID: "dep-sales", UserID: types.ParseUserId("usrAAAAAAAAAAA"), DisplayName: "张三", Position: "销售经理", Status: "active", Contactable: true, Version: 1},
}}
store.Organization = org
defer func() { store.Organization = oldOrganization }()
req := httptest.NewRequest(http.MethodGet, "/v1/im/organization/members?department_id=dep-sales&q=%E5%BC%A0", nil)
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMOrganizationMembers(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
items := data["items"].([]any)
if len(items) != 1 || org.departmentID != "dep-sales" || org.query != "张" || data["has_more"] != false {
t.Fatalf("unexpected organization members response: %#v department=%q query=%q", data, org.departmentID, org.query)
}
}
func TestServeIMOrganizationSearch(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
@@ -2144,6 +2308,41 @@ func TestServeIMMonthlyReportDraftAndSubmit(t *testing.T) {
}
}
func TestServeIMMonthlyReportReopen(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldMonthlyReports := store.MonthlyReports
now := time.Now().UTC().Round(time.Millisecond)
submittedAt := now.Add(-time.Hour)
reports := &testMonthlyReportsStore{report: &types.MonthlyReport{
ReportID: "mr_test",
AuthorID: 42,
Month: "2026-08",
Status: types.DailyReportStatusSubmitted,
ReadState: "unread",
Version: 3,
SubmittedAt: &submittedAt,
CreatedAt: now,
UpdatedAt: now,
}}
store.MonthlyReports = reports
defer func() { store.MonthlyReports = oldMonthlyReports }()
req := httptest.NewRequest(http.MethodPost, "/v1/im/reports/monthly/mr_test/reopen", strings.NewReader(`{"version":3}`))
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMMonthlyReportResource(rec, req)
if rec.Code != http.StatusOK {
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["status"] != types.DailyReportStatusDraft || data["action"] != "edit" || reports.report.SubmittedAt != nil {
t.Fatalf("unexpected reopen response: %#v report=%#v", data, reports.report)
}
}
func TestServeIMMonthlyReportStatistics(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
@@ -3001,6 +3200,93 @@ func TestServeIMGroupInvitationAddsNonMember(t *testing.T) {
}
}
func TestServeIMGroupInvitationAccept(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 43)
oldGroupBusiness := store.GroupBusiness
groupStore := &testGroupBusinessStore{invitations: []types.GroupInvitation{{
InvitationID: "ginv_test",
Topic: "grpAlpha",
InviterUserID: 42,
TargetUserID: 43,
Status: "pending",
ExpiresAt: time.Now().UTC().Add(time.Hour),
}}}
store.GroupBusiness = groupStore
defer func() { store.GroupBusiness = oldGroupBusiness }()
req := httptest.NewRequest(http.MethodPost, "/v1/im/group-invitations/ginv_test/accept", nil)
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMGroupInvitationResource(rec, req)
if rec.Code != http.StatusOK {
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["invitation_id"] != "ginv_test" || data["status"] != types.GroupInvitationStatusJoined || data["result"] != "joined" {
t.Fatalf("unexpected invitation accept response: %#v", data)
}
}
func TestServeIMGroupFiles(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldSubs := store.Subs
oldGroupBusiness := store.GroupBusiness
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
groupStore := &testGroupBusinessStore{}
store.Subs = subs
store.GroupBusiness = groupStore
defer func() {
store.Subs = oldSubs
store.GroupBusiness = oldGroupBusiness
}()
subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(&types.Subscription{
TenantID: 7, User: types.Uid(42).String(), Topic: "grpAlpha", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull,
}, nil).Times(3)
createReq := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/files",
strings.NewReader(`{"file_id":"file1","file_name":"方案.pdf","file_type":"document","file_size":123}`))
createReq.Header.Set("Authorization", "Bearer token-1")
createReq.Header.Set("Idempotency-Key", "gfile-1")
createRec := httptest.NewRecorder()
serveIMGroupResource(createRec, createReq)
if createRec.Code != http.StatusOK {
t.Fatalf("create status: got %d body=%s", createRec.Code, createRec.Body.String())
}
created := decodeIMHTTPResponse(t, createRec).Data.(map[string]any)
if created["file_id"] != "file1" || created["file_type"] != "document" {
t.Fatalf("unexpected create response: %#v", created)
}
listReq := httptest.NewRequest(http.MethodGet, "/v1/im/groups/grpAlpha/files", nil)
listReq.Header.Set("Authorization", "Bearer token-1")
listRec := httptest.NewRecorder()
serveIMGroupResource(listRec, listReq)
if listRec.Code != http.StatusOK {
t.Fatalf("list status: got %d body=%s", listRec.Code, listRec.Body.String())
}
listed := decodeIMHTTPResponse(t, listRec).Data.(map[string]any)
if len(listed["items"].([]any)) != 1 || listed["has_more"] != false {
t.Fatalf("unexpected list response: %#v", listed)
}
deleteReq := httptest.NewRequest(http.MethodDelete, "/v1/im/groups/grpAlpha/files/gfile_test", nil)
deleteReq.Header.Set("Authorization", "Bearer token-1")
deleteRec := httptest.NewRecorder()
serveIMGroupResource(deleteRec, deleteReq)
if deleteRec.Code != http.StatusOK {
t.Fatalf("delete status: got %d body=%s", deleteRec.Code, deleteRec.Body.String())
}
deleted := decodeIMHTTPResponse(t, deleteRec).Data.(map[string]any)
if deleted["status"] != "deleted" || deleted["deleted_by"] != types.Uid(42).UserId() {
t.Fatalf("unexpected delete response: %#v", deleted)
}
}
func TestServeIMGroupQRCodesAndResolve(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
@@ -3950,6 +4236,104 @@ func TestServeIMConversationContext(t *testing.T) {
}
}
func TestServeIMP2PConversation(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 1)
oldConversations := store.Conversations
oldRelations := store.Relations
peer := types.Uid(2)
conversations := &testConversationsStore{}
store.Conversations = conversations
store.Relations = &testRelationsStore{}
defer func() {
store.Conversations = oldConversations
store.Relations = oldRelations
}()
req := httptest.NewRequest(http.MethodPost, "/v1/im/conversations/p2p", strings.NewReader(`{"peer_user_id":"`+peer.UserId()+`"}`))
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMP2PConversation(rec, req)
if rec.Code != http.StatusOK {
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"] != types.Uid(1).P2PName(peer) || data["can_send"] != true {
t.Fatalf("unexpected p2p response: %#v topic=%q", data, conversations.topic)
}
}
func TestServeIMConversationSettings(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 1)
oldConversations := store.Conversations
topic := types.Uid(1).P2PName(types.Uid(2))
conversations := &testConversationsStore{}
store.Conversations = conversations
defer func() { store.Conversations = oldConversations }()
req := httptest.NewRequest(http.MethodPatch, "/v1/im/conversations/"+topic+"/settings", strings.NewReader(`{"pinned":true,"mute_notifications":true,"remark":"同事","version":1}`))
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMConversationContext(rec, req)
if rec.Code != http.StatusOK {
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["pinned"] != true || data["mute_notifications"] != true || data["remark"] != "同事" || data["version"].(float64) != 2 {
t.Fatalf("unexpected settings response: %#v", data)
}
if conversations.settingsPatch.Muted == nil || *conversations.settingsPatch.Muted != true {
t.Fatalf("mute_notifications was not mapped to Muted patch: %#v", conversations.settingsPatch)
}
}
func TestServeIMTopicDraftPutAndGet(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 1)
oldMaxMessageSize := globals.maxMessageSize
globals.maxMessageSize = 1024
defer func() { globals.maxMessageSize = oldMaxMessageSize }()
oldConversations := store.Conversations
topic := "grpDraft"
conversations := &testConversationsStore{}
store.Conversations = conversations
defer func() { store.Conversations = oldConversations }()
req := httptest.NewRequest(http.MethodPut, "/v1/im/topics/"+topic+"/draft", strings.NewReader(`{"content":"未发送草稿","entities":[{"type":"mention"}],"version":0}`))
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMTopicMessageAction(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("put status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if !conversations.putDraftCalled {
t.Fatal("expected draft store to be called")
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if data["content"] != "未发送草稿" || data["version"].(float64) != 1 {
t.Fatalf("unexpected draft put response: %#v", data)
}
req = httptest.NewRequest(http.MethodGet, "/v1/im/topics/"+topic+"/draft", nil)
req.Header.Set("Authorization", "Bearer token-1")
rec = httptest.NewRecorder()
serveIMTopicMessageAction(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("get status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
data = decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if data["content"] != "未发送草稿" {
t.Fatalf("unexpected draft get response: %#v", data)
}
}
func TestServeIMFriendRequestCreate(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
+4
View File
@@ -790,11 +790,13 @@ func main() {
mux.HandleFunc(config.ApiPath+"v1/im/security/challenges", serveIMSecurityChallenges)
mux.HandleFunc(config.ApiPath+"v1/im/security/challenges/", serveIMSecurityChallengeVerify)
mux.HandleFunc(config.ApiPath+"v1/im/security/realname", serveIMRealName)
mux.HandleFunc(config.ApiPath+"v1/im/security/realname/appeals", serveIMRealNameAppeals)
mux.HandleFunc(config.ApiPath+"v1/im/security/realname/submissions", serveIMRealNameSubmissions)
mux.HandleFunc(config.ApiPath+"v1/im/bootstrap", serveIMBootstrap)
mux.HandleFunc(config.ApiPath+"v1/im/workbench", serveIMWorkbench)
mux.HandleFunc(config.ApiPath+"v1/im/organization/departments", serveIMOrganizationDepartments)
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/meetings", serveIMMeetings)
mux.HandleFunc(config.ApiPath+"v1/im/meetings/", serveIMMeetingResource)
@@ -822,6 +824,7 @@ func main() {
mux.HandleFunc(config.ApiPath+"v1/im/settings/independent-password/verify", serveIMIndependentPasswordVerify)
mux.HandleFunc(config.ApiPath+"v1/im/devices/", serveIMDevicePushToken)
mux.HandleFunc(config.ApiPath+"v1/im/search/conversations", serveIMConversationSearch)
mux.HandleFunc(config.ApiPath+"v1/im/conversations/p2p", serveIMP2PConversation)
mux.HandleFunc(config.ApiPath+"v1/im/forward-targets", serveIMForwardTargets)
mux.HandleFunc(config.ApiPath+"v1/im/system-notifications", serveIMSystemNotifications)
mux.HandleFunc(config.ApiPath+"v1/im/system-notifications/", serveIMSystemNotificationRead)
@@ -830,6 +833,7 @@ func main() {
mux.HandleFunc(config.ApiPath+"v1/im/calls", serveIMCalls)
mux.HandleFunc(config.ApiPath+"v1/im/topics/", serveIMTopicMessageAction)
mux.HandleFunc(config.ApiPath+"v1/im/conversations/", serveIMConversationContext)
mux.HandleFunc(config.ApiPath+"v1/im/group-invitations/", serveIMGroupInvitationResource)
mux.HandleFunc(config.ApiPath+"v1/im/groups", serveIMGroups)
mux.HandleFunc(config.ApiPath+"v1/im/groups/", serveIMGroupResource)
mux.HandleFunc(config.ApiPath+"v1/im/me/qrcodes", serveIMMeQRCodes)
+199
View File
@@ -1045,6 +1045,114 @@ func (messagesMapper) Edit(tenantID types.TenantID, topic string, seq int, actor
return tenantAdp.TenantMessageEdit(tenantID, topic, seq, actor, expectedVersion, head, content, reason, editedAt)
}
type ConversationsPersistenceInterface interface {
EnsureP2P(tenantID types.TenantID, uid, peer types.Uid) (string, error)
GetSettings(tenantID types.TenantID, uid types.Uid, topic string) (*types.ConversationSettings, error)
UpdateSettings(tenantID types.TenantID, uid types.Uid, topic string, patch types.ConversationSettingsPatch) (*types.ConversationSettings, error)
GetDraft(tenantID types.TenantID, uid types.Uid, topic string) (*types.TopicDraft, error)
PutDraft(tenantID types.TenantID, uid types.Uid, topic, content string, entities []byte, version int) (*types.TopicDraft, error)
}
type conversationsMapper struct{}
var Conversations ConversationsPersistenceInterface
func (conversationsMapper) EnsureP2P(tenantID types.TenantID, uid, peer types.Uid) (string, error) {
if uid.IsZero() || peer.IsZero() || uid == peer {
return "", types.ErrMalformed
}
canSend, _, err := Relations.CanSendP2P(tenantID, uid, peer)
if err != nil {
return "", err
}
if !canSend {
return "", types.ErrPermissionDenied
}
topic := uid.P2PName(peer)
if topic == "" {
return "", types.ErrMalformed
}
now := types.TimeNow()
initiator := &types.Subscription{
ObjHeader: types.ObjHeader{CreatedAt: now, UpdatedAt: now},
TenantID: tenantID,
User: uid.String(),
Topic: topic,
ModeWant: types.ModeCP2P,
ModeGiven: types.ModeCP2P,
Private: map[string]any{"peer": peer.UserId(), "created_by": "http"},
}
invited := &types.Subscription{
ObjHeader: types.ObjHeader{CreatedAt: now, UpdatedAt: now},
TenantID: tenantID,
User: peer.String(),
Topic: topic,
ModeWant: types.ModeCP2P,
ModeGiven: types.ModeCP2P,
Private: map[string]any{"peer": uid.UserId(), "created_by": "http"},
}
if err = Topics.CreateP2P(tenantID, initiator, invited); err != nil {
return "", err
}
return topic, nil
}
func (conversationsMapper) GetSettings(tenantID types.TenantID, uid types.Uid, topic string) (*types.ConversationSettings, error) {
if uid.IsZero() || strings.TrimSpace(topic) == "" {
return nil, types.ErrMalformed
}
if _, err := Subs.Get(tenantID, strings.TrimSpace(topic), uid, false); err != nil {
return nil, err
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return nil, err
}
return tenantAdp.TenantConversationSettingsGet(tenantID, uid, strings.TrimSpace(topic))
}
func (conversationsMapper) UpdateSettings(tenantID types.TenantID, uid types.Uid, topic string, patch types.ConversationSettingsPatch) (*types.ConversationSettings, error) {
if uid.IsZero() || strings.TrimSpace(topic) == "" || patch.Version <= 0 {
return nil, types.ErrMalformed
}
if _, err := Subs.Get(tenantID, strings.TrimSpace(topic), uid, false); err != nil {
return nil, err
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return nil, err
}
return tenantAdp.TenantConversationSettingsUpdate(tenantID, uid, strings.TrimSpace(topic), patch)
}
func (conversationsMapper) GetDraft(tenantID types.TenantID, uid types.Uid, topic string) (*types.TopicDraft, error) {
if uid.IsZero() || strings.TrimSpace(topic) == "" {
return nil, types.ErrMalformed
}
if _, err := Subs.Get(tenantID, strings.TrimSpace(topic), uid, false); err != nil {
return nil, err
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return nil, err
}
return tenantAdp.TenantTopicDraftGet(tenantID, uid, strings.TrimSpace(topic))
}
func (conversationsMapper) PutDraft(tenantID types.TenantID, uid types.Uid, topic, content string, entities []byte, version int) (*types.TopicDraft, error) {
if uid.IsZero() || strings.TrimSpace(topic) == "" || version < 0 {
return nil, types.ErrMalformed
}
if _, err := Subs.Get(tenantID, strings.TrimSpace(topic), uid, false); err != nil {
return nil, err
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return nil, err
}
return tenantAdp.TenantTopicDraftPut(tenantID, uid, strings.TrimSpace(topic), content, entities, version)
}
type CallsPersistenceInterface interface {
Create(tenantID types.TenantID, record *types.CallRecord) error
Accept(tenantID types.TenantID, callID string, seq int, acceptedAt time.Time) error
@@ -1120,6 +1228,7 @@ type GroupBusinessPersistenceInterface interface {
ListMembers(types.TenantID, string, types.GroupMemberListOptions) ([]types.GroupMember, string, error)
UpdateMember(types.TenantID, string, types.Uid, types.GroupMemberPatch) (*types.GroupMember, error)
CreateInvitation(types.TenantID, *types.GroupInvitation) (*types.GroupInvitation, error)
AcceptInvitation(types.TenantID, string, types.Uid, any, time.Time) (*types.GroupInvitation, error)
CreateJoinRequest(types.TenantID, *types.GroupJoinRequest) (*types.GroupJoinRequest, error)
ListJoinRequests(types.TenantID, string, string, string, int) ([]types.GroupJoinRequest, string, error)
ResolveJoinRequest(types.TenantID, string, string, types.Uid, string, string, int, any, time.Time) (*types.GroupJoinRequest, error)
@@ -1129,6 +1238,9 @@ type GroupBusinessPersistenceInterface interface {
ListMutes(types.TenantID, string, string, int) ([]types.GroupMute, string, error)
UpsertMute(types.TenantID, *types.GroupMute) (*types.GroupMute, error)
DeleteMute(types.TenantID, string, types.Uid, types.Uid, time.Time) error
ListFiles(types.TenantID, string, string, string, string, int) ([]types.GroupFile, string, error)
CreateFile(types.TenantID, *types.GroupFile) (*types.GroupFile, error)
DeleteFile(types.TenantID, string, string, types.Uid, bool, time.Time) (*types.GroupFile, error)
InviteMember(types.TenantID, string, types.Uid, types.Uid, string, string, any, time.Time) (bool, error)
Leave(types.TenantID, string, types.Uid, time.Time) error
UpdateRole(types.TenantID, string, types.Uid, types.Uid, string, types.AccessMode, time.Time) error
@@ -1248,6 +1360,17 @@ func (groupBusinessMapper) CreateInvitation(tenantID types.TenantID, invitation
return tenantAdp.TenantGroupInvitationCreate(tenantID, invitation)
}
func (groupBusinessMapper) AcceptInvitation(tenantID types.TenantID, invitationID string, uid types.Uid, memberPrivate any, now time.Time) (*types.GroupInvitation, error) {
if strings.TrimSpace(invitationID) == "" || uid.IsZero() || now.IsZero() {
return nil, types.ErrMalformed
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return nil, err
}
return tenantAdp.TenantGroupInvitationAccept(tenantID, strings.TrimSpace(invitationID), uid, memberPrivate, now)
}
func (groupBusinessMapper) CreateJoinRequest(tenantID types.TenantID, req *types.GroupJoinRequest) (*types.GroupJoinRequest, error) {
if req == nil || req.Topic == "" || req.ApplicantUserID.IsZero() || req.RequestID == "" {
return nil, types.ErrMalformed
@@ -1353,6 +1476,42 @@ func (groupBusinessMapper) DeleteMute(tenantID types.TenantID, topic string, uid
return tenantAdp.TenantGroupMuteDelete(tenantID, strings.TrimSpace(topic), uid, actor, now)
}
func (groupBusinessMapper) ListFiles(tenantID types.TenantID, topic, fileType, query, cursor string, limit int) ([]types.GroupFile, string, error) {
if strings.TrimSpace(topic) == "" {
return nil, "", types.ErrMalformed
}
if limit <= 0 || limit > 100 {
limit = 50
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return nil, "", err
}
return tenantAdp.TenantGroupFileList(tenantID, strings.TrimSpace(topic), strings.TrimSpace(fileType), strings.TrimSpace(query), strings.TrimSpace(cursor), limit)
}
func (groupBusinessMapper) CreateFile(tenantID types.TenantID, file *types.GroupFile) (*types.GroupFile, error) {
if file == nil || strings.TrimSpace(file.Topic) == "" || strings.TrimSpace(file.FileID) == "" || file.UploaderID.IsZero() {
return nil, types.ErrMalformed
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return nil, err
}
return tenantAdp.TenantGroupFileCreate(tenantID, file)
}
func (groupBusinessMapper) DeleteFile(tenantID types.TenantID, topic, groupFileID string, actor types.Uid, canManage bool, now time.Time) (*types.GroupFile, error) {
if strings.TrimSpace(topic) == "" || strings.TrimSpace(groupFileID) == "" || actor.IsZero() || now.IsZero() {
return nil, types.ErrMalformed
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return nil, err
}
return tenantAdp.TenantGroupFileDelete(tenantID, strings.TrimSpace(topic), strings.TrimSpace(groupFileID), actor, canManage, now)
}
func (groupBusinessMapper) InviteMember(tenantID types.TenantID, topic string, inviter, target types.Uid,
message, idempotencyKey string, private any, now time.Time) (bool, error) {
if strings.TrimSpace(topic) == "" || inviter.IsZero() || target.IsZero() || now.IsZero() {
@@ -1456,6 +1615,7 @@ func (workbenchMapper) Get(tenantID types.TenantID, uid types.Uid) ([]types.Work
type OrganizationPersistenceInterface interface {
ListDepartments(tenantID types.TenantID, uid types.Uid, parentID, cursor string, limit int) ([]types.OrganizationDepartment, string, error)
ListMembers(tenantID types.TenantID, uid types.Uid, departmentID, cursor string, limit int) ([]types.OrganizationMember, string, error)
ListAllMembers(tenantID types.TenantID, uid types.Uid, departmentID, queryText, cursor string, limit int) ([]types.OrganizationMember, string, error)
Search(tenantID types.TenantID, uid types.Uid, queryText, resultTypes, cursor string, limit int) ([]types.OrganizationSearchItem, string, error)
}
@@ -1491,6 +1651,20 @@ func (organizationMapper) ListMembers(tenantID types.TenantID, uid types.Uid, de
return tenantAdp.TenantOrganizationMembers(tenantID, uid, strings.TrimSpace(departmentID), strings.TrimSpace(cursor), limit)
}
func (organizationMapper) ListAllMembers(tenantID types.TenantID, uid types.Uid, departmentID, queryText, cursor string, limit int) ([]types.OrganizationMember, string, error) {
if uid.IsZero() {
return nil, "", types.ErrMalformed
}
if limit <= 0 || limit > 100 {
limit = 50
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return nil, "", err
}
return tenantAdp.TenantOrganizationMembersAll(tenantID, uid, strings.TrimSpace(departmentID), strings.TrimSpace(queryText), strings.TrimSpace(cursor), limit)
}
func (organizationMapper) Search(tenantID types.TenantID, uid types.Uid, queryText, resultTypes, cursor string, limit int) ([]types.OrganizationSearchItem, string, error) {
if uid.IsZero() || strings.TrimSpace(queryText) == "" {
return nil, "", types.ErrMalformed
@@ -1714,6 +1888,7 @@ type MonthlyReportsPersistenceInterface interface {
Get(tenantID types.TenantID, uid types.Uid, reportID string) (*types.MonthlyReport, error)
SaveDraft(tenantID types.TenantID, uid types.Uid, reportID, workSummary, nextPlan string, attachments []string, version int) (*types.MonthlyReport, error)
Submit(tenantID types.TenantID, uid types.Uid, reportID string, version int) (*types.MonthlyReport, error)
Reopen(tenantID types.TenantID, uid types.Uid, reportID string, version int) (*types.MonthlyReport, error)
Stats(tenantID types.TenantID, uid types.Uid, month, departmentID string) (*types.MonthlyReportStats, error)
}
@@ -1779,6 +1954,17 @@ func (monthlyReportsMapper) Submit(tenantID types.TenantID, uid types.Uid, repor
return tenantAdp.TenantMonthlyReportSubmit(tenantID, uid, strings.TrimSpace(reportID), version)
}
func (monthlyReportsMapper) Reopen(tenantID types.TenantID, uid types.Uid, reportID string, version int) (*types.MonthlyReport, error) {
if uid.IsZero() || strings.TrimSpace(reportID) == "" || version <= 0 {
return nil, types.ErrMalformed
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return nil, err
}
return tenantAdp.TenantMonthlyReportReopen(tenantID, uid, strings.TrimSpace(reportID), version)
}
func (monthlyReportsMapper) Stats(tenantID types.TenantID, uid types.Uid, month, departmentID string) (*types.MonthlyReportStats, error) {
if uid.IsZero() || strings.TrimSpace(month) == "" {
return nil, types.ErrMalformed
@@ -3118,6 +3304,7 @@ type UserSecurityPersistenceInterface interface {
BumpAuthVersion(tenantID types.TenantID, uid types.Uid, updatedAt time.Time) (int, error)
GetRealName(tenantID types.TenantID, uid types.Uid) (*types.RealNameVerification, error)
CreateRealNameSubmission(tenantID types.TenantID, rv *types.RealNameVerification) error
CreateRealNameAppeal(tenantID types.TenantID, appeal *types.RealNameAppeal) (*types.RealNameAppeal, error)
CreateActionToken(tenantID types.TenantID, token *types.ActionToken) error
ConsumeActionToken(tenantID types.TenantID, uid types.Uid, token, action, target string, now time.Time) (*types.ActionToken, error)
GetIndependentPassword(tenantID types.TenantID, uid types.Uid, purpose string) (*types.IndependentPassword, error)
@@ -3171,6 +3358,17 @@ func (userSecurityMapper) CreateRealNameSubmission(tenantID types.TenantID, rv *
return tenantAdp.TenantRealNameCreate(tenantID, rv)
}
func (userSecurityMapper) CreateRealNameAppeal(tenantID types.TenantID, appeal *types.RealNameAppeal) (*types.RealNameAppeal, error) {
if appeal == nil || appeal.TenantID != tenantID || appeal.UserID.IsZero() || strings.TrimSpace(appeal.AppealID) == "" {
return nil, types.ErrMalformed
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return nil, err
}
return tenantAdp.TenantRealNameAppealCreate(tenantID, appeal)
}
func (userSecurityMapper) CreateActionToken(tenantID types.TenantID, token *types.ActionToken) error {
if token == nil || token.TenantID != tenantID {
return types.ErrMalformed
@@ -3481,6 +3679,7 @@ func init() {
Topics = topicsMapper{}
Subs = subsMapper{}
Messages = messagesMapper{}
Conversations = conversationsMapper{}
Calls = callsMapper{}
GroupBusiness = groupBusinessMapper{}
Workbench = workbenchMapper{}
+37
View File
@@ -0,0 +1,37 @@
package types
import "time"
type ConversationSettings struct {
ID int64 `db:"id"`
UserID Uid `db:"-"`
Topic string `db:"topic"`
Pinned bool `db:"pinned"`
Muted bool `db:"muted"`
Remark string `db:"remark"`
MessageRetention string `db:"message_retention"`
Version int `db:"version"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
UserIDRaw int64 `db:"user_id"`
}
type ConversationSettingsPatch struct {
Pinned *bool
Muted *bool
Remark *string
MessageRetention *string
Version int
}
type TopicDraft struct {
ID int64 `db:"id"`
UserID Uid `db:"-"`
Topic string `db:"topic"`
Content string `db:"content"`
EntitiesJSON []byte `db:"entities_json"`
Version int `db:"version"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
UserIDRaw int64 `db:"user_id"`
}
+22
View File
@@ -181,6 +181,28 @@ type GroupMute struct {
RevokedRaw *int64 `db:"revoked_by"`
}
type GroupFile struct {
ID int64 `db:"id"`
GroupFileID string `db:"group_file_id"`
Topic string `db:"topic"`
FileID string `db:"file_id"`
SourceTopic string `db:"source_topic"`
SourceSeq int `db:"source_seq"`
UploaderID Uid `db:"-"`
FileName string `db:"file_name"`
MIMEType string `db:"mime_type"`
FileType string `db:"file_type"`
FileSize int64 `db:"file_size"`
Status string `db:"status"`
DeletedBy Uid `db:"-"`
DeletedAt *time.Time `db:"deleted_at"`
IdempotencyKey string `db:"idempotency_key"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
UploaderRaw int64 `db:"uploader_id"`
DeletedByRaw *int64 `db:"deleted_by"`
}
type GroupEvent struct {
ID int64 `db:"id"`
EventID string `db:"event_id"`
+15
View File
@@ -21,6 +21,21 @@ type RealNameVerification struct {
UpdatedAt time.Time `db:"updated_at" json:"-"`
}
// RealNameAppeal is a user-submitted request to correct a real-name review.
type RealNameAppeal struct {
ID int64 `db:"id" json:"-"`
TenantID TenantID `db:"tenant_id" json:"-"`
UserID Uid `db:"user_id" json:"-"`
AppealID string `db:"appeal_id" json:"appeal_id"`
Reason string `db:"reason" json:"reason"`
AttachmentsJSON []byte `db:"attachments_json" json:"-"`
Attachments []string `db:"-" json:"attachments,omitempty"`
Status string `db:"status" json:"status"`
ReviewComment string `db:"review_comment" json:"review_comment,omitempty"`
CreatedAt time.Time `db:"created_at" json:"-"`
UpdatedAt time.Time `db:"updated_at" json:"-"`
}
// ActionToken is a short-lived one-time token for sensitive actions.
type ActionToken struct {
ID int64 `db:"id" json:"-"`
+12
View File
@@ -221,6 +221,7 @@ type testSecurityStore struct {
bumpedUser types.Uid
realName *types.RealNameVerification
realNameAppeal *types.RealNameAppeal
actionToken *types.ActionToken
independentPassword *types.IndependentPassword
youthMode *types.YouthModeSettings
@@ -276,6 +277,17 @@ func (s *testSecurityStore) CreateRealNameSubmission(_ types.TenantID, rv *types
return s.err
}
func (s *testSecurityStore) CreateRealNameAppeal(_ types.TenantID, appeal *types.RealNameAppeal) (*types.RealNameAppeal, error) {
s.realNameAppeal = appeal
if s.err != nil {
return nil, s.err
}
now := time.Now().UTC().Round(time.Millisecond)
appeal.CreatedAt = now
appeal.UpdatedAt = now
return appeal, nil
}
func (s *testSecurityStore) CreateActionToken(_ types.TenantID, token *types.ActionToken) error {
s.actionToken = token
return s.err