Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 76 additions & 10 deletions app-go/app-auth/internal/service/auth_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"log/slog"
"regexp"
"strings"
"sync"
"time"

"app-auth/internal/config"
Expand Down Expand Up @@ -103,20 +104,40 @@ type AuthService struct {
oauth *repo.OAuthRepo
devices *repo.DeviceRepo
rbac *repo.RBACRepo

// bilibili binding status is resolved with a live api.bilibili.com call,
// which can take seconds on some networks. The account page must not block
// on it: cache per uid for a short TTL and hard-cap the upstream call.
biliMu sync.Mutex
biliCache map[int64]biliCacheEntry
}

type biliCacheEntry struct {
status BilibiliStatus
at time.Time
}

const (
// short enough that a fresh bind shows up quickly, long enough that repeat
// account-page opens do not hammer the B站 API
biliStatusTTL = 60 * time.Second
// the live check is best-effort: beyond this the page gets a fallback status
biliStatusTimeout = 2500 * time.Millisecond
)

func NewAuthService(db *gorm.DB, cfg *config.Config, cipher *security.Cipher,
sf *security.Snowflake, tokens *TokenService, rbac *RBACService) *AuthService {
return &AuthService{
db: db,
cfg: cfg,
cipher: cipher,
sf: sf,
tokens: tokens,
users: repo.NewUserRepo(),
oauth: repo.NewOAuthRepo(),
devices: repo.NewDeviceRepo(),
rbac: rbac.repoRef(),
db: db,
cfg: cfg,
cipher: cipher,
sf: sf,
tokens: tokens,
users: repo.NewUserRepo(),
oauth: repo.NewOAuthRepo(),
devices: repo.NewDeviceRepo(),
rbac: rbac.repoRef(),
biliCache: make(map[int64]biliCacheEntry),
}
}

Expand Down Expand Up @@ -218,6 +239,8 @@ func (s *AuthService) EnsureUserFromOAuth(provider, providerUID string, data *Pr
slog.Info("[USER] created user", "uid", uid, "provider", provider)
}

s.invalidateBiliStatus(uid)

token, err := s.tokens.Issue(context.Background(), uid, deviceID, ip, userAgent)
if err != nil {
return 0, "", err
Expand All @@ -229,6 +252,7 @@ func (s *AuthService) EnsureUserFromOAuth(provider, providerUID string, data *Pr
// user. Refuses bindings owned by another user.
func (s *AuthService) BindOAuthToUser(uid int64, provider, providerUID string,
data *ProviderData, profile *ProfilePatch) error {
s.invalidateBiliStatus(uid) // a bind/refresh must not serve a stale cached status
user, err := s.users.GetByUID(s.db, uid)
if err != nil {
return err
Expand Down Expand Up @@ -788,12 +812,54 @@ func (s *AuthService) GetSecurityInfo(uid int64, passport *BilibiliPassport) (*S
"is_primary": primary,
})
if b.Provider == "bilibili" {
out.Bilibili = s.bilibiliStatus(&b, passport)
out.Bilibili = s.bilibiliStatusCached(uid, &b, passport)
}
}
return out, nil
}

// bilibiliStatusCached wraps the live B站 check with a per-uid TTL cache and a
// hard deadline. GET /auth/security is on the account page's critical path; the
// upstream call was observed taking 2–8s (api.bilibili.com reachability), which
// made the whole page wait on it.
func (s *AuthService) bilibiliStatusCached(uid int64, b *model.UserOAuth, passport *BilibiliPassport) BilibiliStatus {
s.biliMu.Lock()
if entry, ok := s.biliCache[uid]; ok && time.Since(entry.at) < biliStatusTTL {
s.biliMu.Unlock()
return entry.status
}
s.biliMu.Unlock()

ch := make(chan BilibiliStatus, 1)
go func() { ch <- s.bilibiliStatus(b, passport) }()

select {
case st := <-ch:
s.biliMu.Lock()
if len(s.biliCache) > 5000 {
s.biliCache = make(map[int64]biliCacheEntry)
}
s.biliCache[uid] = biliCacheEntry{status: st, at: time.Now()}
s.biliMu.Unlock()
return st
case <-time.After(biliStatusTimeout):
st := BilibiliStatus{Bound: true, Valid: false, Message: "B站状态检查超时,请稍后重试"}
if isDigits(b.ProviderUID) {
mid := parseInt64(b.ProviderUID)
st.Mid = &mid
}
// not cached: the next open retries the live check
return st
}
}

// invalidateBiliStatus drops the cached binding status (called on bind/unbind).
func (s *AuthService) invalidateBiliStatus(uid int64) {
s.biliMu.Lock()
delete(s.biliCache, uid)
s.biliMu.Unlock()
}

func (s *AuthService) bilibiliStatus(b *model.UserOAuth, passport *BilibiliPassport) BilibiliStatus {
status := BilibiliStatus{Bound: true, Message: "B站登录已失效,请重新扫码"}
if isDigits(b.ProviderUID) {
Expand Down
31 changes: 31 additions & 0 deletions app-go/app-cloud/internal/dto/cloud_ai.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,34 @@ type SemanticSearchResponse struct {
type SimilarResponse struct {
Items []SemanticHit `json:"items"`
}

// KGDocumentsItem is one parsed cloud document listed for cloud-driven KG
// builds. The body text is deliberately absent (fetch it per document); the
// listing must stay small even for users with thousands of documents.
type KGDocumentsItem struct {
UploadUUID string `json:"upload_uuid"`
Title string `json:"title"`
ContentHash string `json:"content_hash"`
// SizeChars is the document's character count (0 = recorded size unknown,
// i.e. parsed before size_chars was persisted). The per-document endpoint
// always reports the exact value.
SizeChars int `json:"size_chars"`
}

// KGDocumentsResponse is one page of the KG document listing. next_cursor is
// the last upload_uuid of a full page and null on the last page.
type KGDocumentsResponse struct {
Items []KGDocumentsItem `json:"items"`
NextCursor *string `json:"next_cursor"`
}

// KGDocumentResponse is one parsed document with its body text (truncated
// server-side to the KG extraction budget). size_chars counts the full text,
// not the returned slice, so callers can tell truncated documents apart.
type KGDocumentResponse struct {
UploadUUID string `json:"upload_uuid"`
Title string `json:"title"`
ContentHash string `json:"content_hash"`
SizeChars int `json:"size_chars"`
Text string `json:"text"`
}
131 changes: 129 additions & 2 deletions app-go/app-cloud/internal/infra/mongostore/mongostore.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"regexp"
"strings"
"time"
"unicode/utf8"

"app-cloud/internal/config"

Expand Down Expand Up @@ -100,6 +101,127 @@ func (m *MongoStore) DocumentPreview(ctx context.Context, uid int64, uploadUUID
return cleaned[offset:end], total, doc.DocMeta
}

// ParsedDocumentMeta is one parsed drive document listed for cloud
// knowledge-graph builds: identity and content version only, never the body.
type ParsedDocumentMeta struct {
UploadUUID string
Title string
ContentHash string
// SizeChars is the character count recorded when the text was persisted.
// The listing deliberately excludes `content`, so no body text is loaded
// (or measured) on the read path; documents written before size_chars
// existed report 0. The per-document endpoint serves the exact value.
SizeChars int
}

// ParsedDocument is one parsed drive document including its full text.
type ParsedDocument struct {
UploadUUID string
Title string
ContentHash string
SizeChars int
Text string
}

// ListParsedDocuments pages one user's parsed documents that have usable body
// text, ordered by upload_uuid ascending; afterUploadUUID (empty = first page)
// is the exclusive lower bound, matching the upload_uuid cursor semantics of
// the KG M2M listing. The projection skips `content`: a 100-document page must
// not drag megabytes of text over the wire — size_chars is read from the value
// persisted by UpsertParsedDocument instead.
func (m *MongoStore) ListParsedDocuments(ctx context.Context, uid int64,
afterUploadUUID string, limit int) ([]ParsedDocumentMeta, error) {
if m == nil || m.db == nil || limit <= 0 {
// Disabled store / no page size: nil-safe empty page, like the other
// readers (the HTTP layer clamps limit into 1..100).
return nil, nil
}
filter := bson.M{
"uid": uid,
"content": usableTextFilter(),
"content_hash": usableTextFilter(),
}
if afterUploadUUID != "" {
filter["upload_uuid"] = bson.M{"$gt": afterUploadUUID}
}
cur, err := m.db.Collection("cloud_drive_documents").Find(ctx, filter,
options.Find().
SetSort(bson.M{"upload_uuid": 1}).
SetLimit(int64(limit)).
SetProjection(bson.M{
"upload_uuid": 1, "title": 1, "content_hash": 1, "size_chars": 1, "_id": 0,
}),
)
if err != nil {
return nil, err
}
defer cur.Close(ctx)
out := make([]ParsedDocumentMeta, 0, limit)
for cur.Next(ctx) {
var row struct {
UploadUUID string `bson:"upload_uuid"`
Title string `bson:"title"`
ContentHash string `bson:"content_hash"`
SizeChars int `bson:"size_chars"`
}
if err := cur.Decode(&row); err != nil {
return nil, err
}
out = append(out, ParsedDocumentMeta{
UploadUUID: row.UploadUUID,
Title: row.Title,
ContentHash: row.ContentHash,
SizeChars: row.SizeChars,
})
}
return out, cur.Err()
}

// GetParsedDocument returns one user's parsed document with its full text.
// (nil, nil) means "absent, no body text, or no content hash" — the HTTP layer
// maps that to 404 without distinguishing the cases (same as the drive detail
// contract). A nil/disabled store behaves identically.
func (m *MongoStore) GetParsedDocument(ctx context.Context, uid int64,
uploadUUID string) (*ParsedDocument, error) {
if m == nil || m.db == nil || uploadUUID == "" {
return nil, nil
}
var row struct {
UploadUUID string `bson:"upload_uuid"`
Title string `bson:"title"`
ContentHash string `bson:"content_hash"`
Content string `bson:"content"`
SizeChars int `bson:"size_chars"`
}
err := m.db.Collection("cloud_drive_documents").FindOne(ctx,
bson.M{"upload_uuid": uploadUUID, "uid": uid,
"content": usableTextFilter(), "content_hash": usableTextFilter()},
options.FindOne().SetProjection(bson.M{
"upload_uuid": 1, "title": 1, "content_hash": 1, "content": 1,
"size_chars": 1, "_id": 0,
}),
).Decode(&row)
if err != nil {
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, nil
}
return nil, err
}
return &ParsedDocument{
UploadUUID: row.UploadUUID,
Title: row.Title,
ContentHash: row.ContentHash,
SizeChars: row.SizeChars,
Text: row.Content,
}, nil
}

// usableTextFilter matches a string field that exists and is neither null nor
// empty (the "parsed正文可用" gate shared by the KG reads).
func usableTextFilter() bson.M {
return bson.M{"$exists": true, "$nin": []any{nil, ""}}
}

// UpsertParsedDocument stores the parsed full text (pipeline phase 1).
func (m *MongoStore) UpsertParsedDocument(ctx context.Context, uploadUUID string, uid int64,
title, sourceType, contentSource, content, contentHash string, docMeta map[string]any) error {
Expand All @@ -116,8 +238,13 @@ func (m *MongoStore) UpsertParsedDocument(ctx context.Context, uploadUUID string
"content_source": contentSource,
"content": content,
"content_hash": contentHash,
"doc_meta": docMeta,
"updated_at": time.Now().UTC(),
// size_chars is persisted so the KG listing can report a size
// without loading (or even transferring) the body text. Rune count
// keeps it consistent with the summary size_chars convention
// (one CJK character = 1), unlike len() which counts UTF-8 bytes.
"size_chars": utf8.RuneCountInString(content),
"doc_meta": docMeta,
"updated_at": time.Now().UTC(),
}},
options.UpdateOne().SetUpsert(true),
)
Expand Down
Loading
Loading