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)} 条
+
+
+
+
- | ID | 内容 | 邮箱 | 语言 | 状态 | 时间 | |
+ | ID | 类型 | 身份/联系 | 内容 | 版本 | 语言 | 状态 | 时间 | |
${list.length ? list.map((f)=>`
- | ${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||'')} |
- |
`).join('') : '| 暂无反馈 |
'}
+ `).join('') : '| 暂无反馈 |
'}
`;
+ $('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);