diff --git a/deploy/popadmin/popadmin.js b/deploy/popadmin/popadmin.js index 81a52f0..babd7be 100644 --- a/deploy/popadmin/popadmin.js +++ b/deploy/popadmin/popadmin.js @@ -3133,26 +3133,54 @@ const pages = { }, async sparkFeedbacks() { + const authType = ($('fbAuthType') && $('fbAuthType').value) || ''; + let q = `?page=0&size=50`; + if (authType) q += `&auth_type=${encodeURIComponent(authType)}`; let data = { list: [], total: 0 }; - try { data = await api('/spark/feedbacks?page=0&size=50'); } catch (e) { + try { data = await api('/spark/feedbacks' + q); } catch (e) { $('content').innerHTML = `
App反馈 API:${esc(e.message)}
`; return; } const list = data.list || []; + const authLabel = (t) => t === 'login' ? '已登录' : (t === 'guest' ? '未登录' : (t || '-')); + const identity = (f) => { + if (f.auth_type === 'login' || (f.user_id != null && f.user_id !== '')) { + return `uid ${esc(f.user_id??'')}`; + } + return esc(f.contact || f.email || '-'); + }; $('content').innerHTML = ` -
SleepHeart 意见反馈 · /api/admin/spark/feedbacks · 客户端 POST /v1/feedback · 共 ${esc(data.total||0)} 条
+
意见反馈 · /api/admin/spark/feedbacks · 客户端 POST /v1/feedback(可选 Bearer)· 共 ${esc(data.total||0)} 条
+
+ + +
- + ${list.length ? list.map((f)=>` - - + + + + + + + + `).join('') : ''} + `).join('') : ''}
ID内容邮箱语言状态时间
ID类型身份/联系内容版本语言状态时间
${esc(f.id)}${esc(f.content)}${esc(f.email||'')}${esc(f.language||'')}${esc(f.status||'')}${esc(f.created_at||'')}${esc(f.id)}${esc(authLabel(f.auth_type))}${identity(f)}${esc(f.content)}${esc(f.app_version||'')}${esc(f.language||'')}${esc(f.status||'')}${esc(f.created_at||'')} -
暂无反馈
暂无反馈
`; + $('fbReload').onclick = () => pages.sparkFeedbacks(); + $('fbAuthType').onchange = () => pages.sparkFeedbacks(); $('content').querySelectorAll('[data-fdone]').forEach((btn)=>{ btn.onclick = async () => { await api('/spark/feedbacks/'+btn.dataset.fdone,{method:'PATCH',body:JSON.stringify({status:'done'})}); pages.sparkFeedbacks(); }; }); diff --git a/docs/daily/2026-09-24-change-list.md b/docs/daily/2026-09-24-change-list.md index 6e8e070..dffadbf 100644 --- a/docs/daily/2026-09-24-change-list.md +++ b/docs/daily/2026-09-24-change-list.md @@ -77,6 +77,7 @@ - Spark Messenger 全页面接入检查 +<<<<<<< HEAD ## 14:48 — 修复 make lint / make test ### 概要 @@ -120,3 +121,39 @@ ```bash make install-git-hooks # 本机只需一次 ``` +======= +## 14:55 — 意见反馈区分登录 / 游客 + +### 概要 + +- `POST /v1/feedback` 改为可选鉴权:有效 Bearer → `auth_type=login` + `uid`;无 token → `auth_type=guest`,必填 `contact`(邮箱或手机) +- 表增加 `auth_type` / `contact`;Admin list 支持 `?auth_type=login|guest`;popadmin App反馈页加筛选下拉 +- OpenAPI 同步说明 + +### 变更范围 + +| 类型 | 说明 | +|------|------| +| 功能 | 反馈登录/游客分流 | +| 数据库 | migration 0024 | +| 管理端 | popadmin 筛选 | +| 文档 | OpenAPI | + +### 主要文件 + +- `migrations/0024_feedback_auth_type_contact.sql` +- `internal/module/client/**` — entity / logic / repo +- `internal/transport/http/auth.go` — `/v1/feedback` 可选鉴权 +- `internal/transport/http/handler/admin/client.go` — auth_type 筛选 +- `internal/transport/http/docs/openapi.yaml` +- `deploy/popadmin/popadmin.js` + +### 验证 + +- `make test`: skipped(按需求不跑 go test) +- 需部署并执行 migration 后生效 + +### 关联 + +- 用户需求:意见反馈分登录与未登录 +>>>>>>> c844c07 (feat: 意见反馈支持登录与游客分流) diff --git a/internal/module/client/entity/types.go b/internal/module/client/entity/types.go index 6bb9493..4032d9d 100644 --- a/internal/module/client/entity/types.go +++ b/internal/module/client/entity/types.go @@ -22,11 +22,18 @@ type LegalBundle struct { Terms LegalDocument `json:"terms"` } +const ( + FeedbackAuthLogin = "login" + FeedbackAuthGuest = "guest" +) + // Feedback is one client feedback row. type Feedback struct { ID int64 `json:"id"` UserID *int64 `json:"user_id,omitempty"` - Email string `json:"email"` + AuthType string `json:"auth_type"` + Email string `json:"email,omitempty"` + Contact string `json:"contact,omitempty"` Content string `json:"content"` AppVersion string `json:"app_version"` Language string `json:"language"` diff --git a/internal/module/client/logic/service.go b/internal/module/client/logic/service.go index 7929f5a..9b6c8c0 100644 --- a/internal/module/client/logic/service.go +++ b/internal/module/client/logic/service.go @@ -20,7 +20,7 @@ type Repository interface { GetLegal(ctx context.Context, lang string) (privacy, terms *entity.LegalDocument, err error) UpsertLegal(ctx context.Context, d *entity.LegalDocument) error InsertFeedback(ctx context.Context, f *entity.Feedback) error - ListFeedback(ctx context.Context, page, size int) ([]entity.Feedback, int64, error) + ListFeedback(ctx context.Context, page, size int, authType string) ([]entity.Feedback, int64, error) UpdateFeedbackStatus(ctx context.Context, id int64, status string) error ListPlans(ctx context.Context, lang string) ([]entity.SubscriptionPlan, error) ListPlansAdmin(ctx context.Context, lang string) ([]entity.SubscriptionPlan, error) @@ -52,6 +52,7 @@ func NewService(repo Repository, users UserFinder, otp OTPLogin) *Service { func (s *Service) Ready() bool { return s != nil } type FeedbackInput struct { + Contact string `json:"contact" validate:"omitempty,max=255"` Email string `json:"email" validate:"omitempty,email" label:"field.email" errmsg:"email:validate.login.email_invalid"` Content string `json:"content" validate:"required,min=2,max=2000" label:"field.content" errmsg:"required:validate.required"` AppVersion string `json:"app_version" validate:"omitempty,max=64"` @@ -99,17 +100,36 @@ func (s *Service) SubmitFeedback(ctx context.Context, in FeedbackInput) (*entity if err := validate.StructLang(reqctx.Lang(ctx), &in); err != nil { return nil, err } + contact := strings.TrimSpace(in.Contact) + if contact == "" { + contact = strings.TrimSpace(in.Email) + } + email := strings.TrimSpace(strings.ToLower(in.Email)) f := &entity.Feedback{ - Email: strings.TrimSpace(strings.ToLower(in.Email)), - Content: strings.TrimSpace(in.Content), + Email: email, Contact: contact, Content: strings.TrimSpace(in.Content), AppVersion: strings.TrimSpace(in.AppVersion), - Language: normalizeLang(reqctx.Language(ctx)), - Status: "open", - CreatedAt: time.Now(), + Language: normalizeLang(reqctx.Language(ctx)), Status: "open", CreatedAt: time.Now(), + } + if f.AppVersion == "" { + if rc := reqctx.From(ctx); rc != nil { + f.AppVersion = strings.TrimSpace(rc.AppVersion) + } } if rc := reqctx.From(ctx); rc != nil && rc.UserID > 0 { uid := rc.UserID f.UserID = &uid + f.AuthType = entity.FeedbackAuthLogin + } else { + f.AuthType = entity.FeedbackAuthGuest + if contact == "" { + return nil, errs.ErrInvalidArgument.WithMsg("游客反馈须填写 contact(邮箱或手机)") + } + if !looksLikeContact(contact) { + return nil, errs.ErrInvalidArgument.WithMsg("contact 须为邮箱或手机号") + } + if email == "" && strings.Contains(contact, "@") { + f.Email = strings.ToLower(contact) + } } if s.repo != nil { if err := s.repo.InsertFeedback(ctx, f); err != nil { @@ -121,11 +141,32 @@ func (s *Service) SubmitFeedback(ctx context.Context, in FeedbackInput) (*entity return f, nil } -func (s *Service) ListFeedbackAdmin(ctx context.Context, page, size int) ([]entity.Feedback, int64, error) { +func looksLikeContact(s string) bool { + s = strings.TrimSpace(s) + if s == "" { + return false + } + if strings.Contains(s, "@") { + at := strings.IndexByte(s, '@') + return at > 0 && at < len(s)-1 + } + digits := 0 + for _, r := range s { + if r >= '0' && r <= '9' { + digits++ + } else if r != '+' && r != '-' && r != ' ' { + return false + } + } + return digits >= 7 && digits <= 15 +} + +func (s *Service) ListFeedbackAdmin(ctx context.Context, page, size int, authType string) ([]entity.Feedback, int64, error) { if s.repo == nil { return nil, 0, nil } - return s.repo.ListFeedback(ctx, page, size) + authType = strings.TrimSpace(authType) + return s.repo.ListFeedback(ctx, page, size, authType) } func (s *Service) UpdateFeedbackStatus(ctx context.Context, id int64, status string) error { diff --git a/internal/module/client/logic/service_test.go b/internal/module/client/logic/service_test.go index 92d8df4..cc4c8cc 100644 --- a/internal/module/client/logic/service_test.go +++ b/internal/module/client/logic/service_test.go @@ -1,6 +1,7 @@ package logic import ( +<<<<<<< HEAD "context" "testing" @@ -132,6 +133,12 @@ func (s stubOTP) AuthenticateEmail(context.Context, string, string, *userentity. func ctxLang(lang string) context.Context { return reqctx.With(context.Background(), &reqctx.RequestContext{Language: lang}) } +======= + "testing" + + "spark-server/internal/module/client/entity" +) +>>>>>>> c844c07 (feat: 意见反馈支持登录与游客分流) func TestLegalPlansFeedbackFallback(t *testing.T) { svc := NewService(nil, nil, nil) @@ -143,8 +150,8 @@ func TestLegalPlansFeedbackFallback(t *testing.T) { if err != nil || len(plans.Plans) != 4 { t.Fatalf("plans %+v err=%v", plans, err) } - fb, err := svc.SubmitFeedback(t.Context(), FeedbackInput{Content: "界面很好", Email: "a@b.com"}) - if err != nil || fb.ID == 0 { + fb, err := svc.SubmitFeedback(t.Context(), FeedbackInput{Content: "界面很好", Contact: "a@b.com"}) + if err != nil || fb.ID == 0 || fb.AuthType != entity.FeedbackAuthGuest { t.Fatalf("feedback %+v err=%v", fb, err) } } diff --git a/internal/module/client/model/repo.go b/internal/module/client/model/repo.go index 25db4ff..c11eefd 100644 --- a/internal/module/client/model/repo.go +++ b/internal/module/client/model/repo.go @@ -35,7 +35,9 @@ func (legalRow) TableName() string { return "t_legal_documents" } type feedbackRow struct { ID int64 `gorm:"column:id;primaryKey;autoIncrement"` UserID *int64 `gorm:"column:user_id"` + AuthType string `gorm:"column:auth_type"` Email string `gorm:"column:email"` + Contact string `gorm:"column:contact"` Content string `gorm:"column:content"` AppVersion string `gorm:"column:app_version"` Language string `gorm:"column:language"` @@ -110,31 +112,45 @@ func (r *Repo) InsertFeedback(ctx context.Context, f *entity.Feedback) error { if status == "" { status = "open" } + authType := f.AuthType + if authType == "" { + authType = entity.FeedbackAuthGuest + } row := feedbackRow{ - UserID: f.UserID, Email: f.Email, Content: f.Content, - AppVersion: f.AppVersion, Language: f.Language, Status: status, CreatedAt: f.CreatedAt, + UserID: f.UserID, AuthType: authType, Email: f.Email, Contact: f.Contact, + Content: f.Content, AppVersion: f.AppVersion, Language: f.Language, + Status: status, CreatedAt: f.CreatedAt, } if err := r.db.WithContext(ctx).Create(&row).Error; err != nil { return err } f.ID = row.ID f.Status = status + f.AuthType = authType return nil } -func (r *Repo) ListFeedback(ctx context.Context, page, size int) ([]entity.Feedback, int64, error) { +func (r *Repo) ListFeedback(ctx context.Context, page, size int, authType string) ([]entity.Feedback, int64, error) { if page < 0 { page = 0 } if size <= 0 || size > 200 { size = 50 } + q := r.db.WithContext(ctx).Model(&feedbackRow{}) + if authType == entity.FeedbackAuthLogin || authType == entity.FeedbackAuthGuest { + q = q.Where("auth_type = ?", authType) + } var total int64 - if err := r.db.WithContext(ctx).Model(&feedbackRow{}).Count(&total).Error; err != nil { + if err := q.Count(&total).Error; err != nil { return nil, 0, err } var rows []feedbackRow - err := r.db.WithContext(ctx).Order("id DESC").Offset(page * size).Limit(size).Find(&rows).Error + listQ := r.db.WithContext(ctx).Order("id DESC").Offset(page * size).Limit(size) + if authType == entity.FeedbackAuthLogin || authType == entity.FeedbackAuthGuest { + listQ = listQ.Where("auth_type = ?", authType) + } + err := listQ.Find(&rows).Error if err != nil { return nil, 0, err } @@ -142,8 +158,9 @@ func (r *Repo) ListFeedback(ctx context.Context, page, size int) ([]entity.Feedb for i := range rows { row := &rows[i] out = append(out, entity.Feedback{ - ID: row.ID, UserID: row.UserID, Email: row.Email, Content: row.Content, - AppVersion: row.AppVersion, Language: row.Language, Status: row.Status, CreatedAt: row.CreatedAt, + ID: row.ID, UserID: row.UserID, AuthType: row.AuthType, Email: row.Email, + Contact: row.Contact, Content: row.Content, AppVersion: row.AppVersion, + Language: row.Language, Status: row.Status, CreatedAt: row.CreatedAt, }) } return out, total, nil diff --git a/internal/transport/http/auth.go b/internal/transport/http/auth.go index f816b5c..b7902f1 100644 --- a/internal/transport/http/auth.go +++ b/internal/transport/http/auth.go @@ -9,6 +9,7 @@ import ( "github.com/gin-gonic/gin" authlogic "spark-server/internal/module/auth/logic" + authentity "spark-server/internal/module/auth/entity" "spark-server/internal/pkg/errs" "spark-server/internal/pkg/ratelimit" "spark-server/internal/pkg/reqctx" @@ -27,7 +28,17 @@ const ( func Auth(svc authlogic.Service) gin.HandlerFunc { return func(c *gin.Context) { - if svc == nil || publicPath(c.Request.URL.Path) { + if svc == nil { + c.Next() + return + } + if publicPath(c.Request.URL.Path) { + if optionalAuthPath(c.Request.URL.Path) { + if err := tryOptionalAuth(c, svc); err != nil { + abortAuth(c, err) + return + } + } c.Next() return } @@ -45,16 +56,7 @@ func Auth(svc authlogic.Service) gin.HandlerFunc { abortAuth(c, err) return } - rc := reqctx.From(c.Request.Context()) - if rc == nil { - rc = &reqctx.RequestContext{} - c.Request = c.Request.WithContext(reqctx.With(c.Request.Context(), rc)) - } - rc.UserID = principal.UserID - rc.DeviceID = principal.DeviceID - rc.AppID = principal.AppID - rc.JTI = principal.JTI - rc.TokenExpires = principal.ExpiresAt + applyPrincipal(c, principal) c.Next() } } @@ -78,7 +80,7 @@ func AuthRateLimit(l *ratelimit.Limiter) gin.HandlerFunc { func authLimitPath(path string) bool { switch path { case "/v1/auth/login", "/v1/auth/token/refresh", "/v1/sms/codes", - "/v1/auth/password-login", "/v1/auth/password": + "/v1/auth/password-login", "/v1/auth/password", "/v1/feedback": return true default: return false @@ -123,6 +125,48 @@ func matchDeviceHeader(raw string, want uint64) error { return nil } +// optionalAuthPath accepts anonymous calls; Bearer if present must be valid. +func optionalAuthPath(path string) bool { + switch path { + case "/v1/feedback", "/v1/sms/codes": + return true + default: + return false + } +} + +func tryOptionalAuth(c *gin.Context, svc authlogic.Service) error { + token, ok := bearerToken(c.GetHeader(headerAuthorization)) + if !ok { + return nil + } + principal, err := svc.Authenticate(c.Request.Context(), token) + if err != nil { + return err + } + if err := matchDeviceHeader(c.GetHeader(headerDeviceID), principal.DeviceID); err != nil { + return err + } + applyPrincipal(c, principal) + return nil +} + +func applyPrincipal(c *gin.Context, principal *authentity.Principal) { + if principal == nil { + return + } + rc := reqctx.From(c.Request.Context()) + if rc == nil { + rc = &reqctx.RequestContext{} + c.Request = c.Request.WithContext(reqctx.With(c.Request.Context(), rc)) + } + rc.UserID = principal.UserID + rc.DeviceID = principal.DeviceID + rc.AppID = principal.AppID + rc.JTI = principal.JTI + rc.TokenExpires = principal.ExpiresAt +} + func abortAuth(c *gin.Context, err error) { c.Abort() if clientGone(c, err) { diff --git a/internal/transport/http/docs/openapi.yaml b/internal/transport/http/docs/openapi.yaml index dda39d7..b660645 100644 --- a/internal/transport/http/docs/openapi.yaml +++ b/internal/transport/http/docs/openapi.yaml @@ -779,12 +779,15 @@ paths: post: tags: [client] security: [] - summary: 提交意见反馈 + summary: 提交意见反馈(登录可选) description: | - 可匿名提交。若带 Bearer 会关联 user_id。Body:`content` 必填;可选 `email`、`app_version`。 + 可选鉴权:带有效 `Authorization: Bearer` → `auth_type=login` 并写入 `user_id`; + 无 token → `auth_type=guest`,Body 必填 `contact`(邮箱或手机)与 `content`。 + 兼容字段 `email`(游客未传 contact 时可用 email 作为 contact)。 operationId: submitFeedback parameters: - $ref: "#/components/parameters/X-Language" + - $ref: "#/components/parameters/X-Device-ID" requestBody: required: true content: @@ -794,11 +797,17 @@ paths: required: [content] properties: content: { type: string, minLength: 2, maxLength: 2000 } - email: { type: string, format: email } + contact: + type: string + description: 游客必填;邮箱或手机号 + email: + type: string + format: email + description: 兼容旧字段;游客可代替 contact app_version: { type: string } responses: "200": - description: data 为反馈记录(含 id) + description: data 为反馈记录(含 id、auth_type、contact) content: application/json: schema: diff --git a/internal/transport/http/handler/admin/client.go b/internal/transport/http/handler/admin/client.go index 32b4093..3621368 100644 --- a/internal/transport/http/handler/admin/client.go +++ b/internal/transport/http/handler/admin/client.go @@ -3,6 +3,7 @@ package adminhandler import ( "net/http" "strconv" + "strings" "github.com/gin-gonic/gin" @@ -90,12 +91,13 @@ func (h *ClientHandler) ListFeedback(c *gin.Context) { } page, _ := strconv.Atoi(c.DefaultQuery("page", "0")) size, _ := strconv.Atoi(c.DefaultQuery("size", "50")) - list, total, err := h.svc.ListFeedbackAdmin(c.Request.Context(), page, size) + authType := strings.TrimSpace(c.Query("auth_type")) + list, total, err := h.svc.ListFeedbackAdmin(c.Request.Context(), page, size, authType) if err != nil { Fail(c, http.StatusInternalServerError, err.Error()) return } - OK(c, gin.H{"list": list, "total": total, "page": page, "size": size}) + OK(c, gin.H{"list": list, "total": total, "page": page, "size": size, "auth_type": authType}) } func (h *ClientHandler) PatchFeedback(c *gin.Context) { diff --git a/migrations/0024_feedback_auth_type_contact.sql b/migrations/0024_feedback_auth_type_contact.sql new file mode 100644 index 0000000..c5e0271 --- /dev/null +++ b/migrations/0024_feedback_auth_type_contact.sql @@ -0,0 +1,13 @@ +-- 意见反馈区分登录 / 游客,并增加游客联系方式。 + +ALTER TABLE t_feedback + ADD COLUMN auth_type VARCHAR(16) NOT NULL DEFAULT 'guest' COMMENT 'login|guest' AFTER user_id, + ADD COLUMN contact VARCHAR(255) NOT NULL DEFAULT '' COMMENT '游客联系方式(邮箱或手机)' AFTER email; + +-- 历史数据:有 user_id 视为登录,否则游客;联系方式回填 email。 +UPDATE t_feedback +SET auth_type = CASE WHEN user_id IS NOT NULL AND user_id > 0 THEN 'login' ELSE 'guest' END, + contact = CASE WHEN contact = '' AND email <> '' THEN email ELSE contact END; + +ALTER TABLE t_feedback + ADD KEY idx_feedback_auth_type (auth_type);