feat: 意见反馈支持登录与游客分流

可选 Bearer 写入 auth_type/uid,游客必填 contact;Admin 与 popadmin 可按类型筛选。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
DESKTOP-L8OUL9F\gfl0034 2026-09-24 16:01:24 +08:00 committed by zhr
parent d5c0d81e22
commit 082f420347
10 changed files with 247 additions and 42 deletions

View File

@ -3133,26 +3133,54 @@ const pages = {
}, },
async sparkFeedbacks() { 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 }; 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 = `<div class="note error">App反馈 API:${esc(e.message)}</div>`; $('content').innerHTML = `<div class="note error">App反馈 API:${esc(e.message)}</div>`;
return; return;
} }
const list = data.list || []; 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 = ` $('content').innerHTML = `
<div class="note">SleepHeart 意见反馈 · <code>/api/admin/spark/feedbacks</code> · 客户端 <code>POST /v1/feedback</code> · 共 ${esc(data.total||0)} 条</div> <div class="note">意见反馈 · <code>/api/admin/spark/feedbacks</code> · 客户端 <code>POST /v1/feedback</code>(可选 Bearer)· 共 ${esc(data.total||0)} 条</div>
<div class="toolbar" style="margin:8px 0;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<label>登录状态
<select id="fbAuthType">
<option value="">全部</option>
<option value="login"${authType==='login'?' selected':''}>已登录</option>
<option value="guest"${authType==='guest'?' selected':''}>未登录</option>
</select>
</label>
<button type="button" id="fbReload" class="secondary">刷新</button>
</div>
<table> <table>
<thead><tr><th>ID</th><th>内容</th><th>邮箱</th><th>语言</th><th>状态</th><th>时间</th><th></th></tr></thead> <thead><tr><th>ID</th><th>类型</th><th>身份/联系</th><th>内容</th><th>版本</th><th>语言</th><th>状态</th><th>时间</th><th></th></tr></thead>
<tbody> <tbody>
${list.length ? list.map((f)=>`<tr> ${list.length ? list.map((f)=>`<tr>
<td>${esc(f.id)}</td><td style="max-width:280px">${esc(f.content)}</td><td>${esc(f.email||'')}</td> <td>${esc(f.id)}</td>
<td>${esc(f.language||'')}</td><td>${esc(f.status||'')}</td><td>${esc(f.created_at||'')}</td> <td>${esc(authLabel(f.auth_type))}</td>
<td>${identity(f)}</td>
<td style="max-width:280px">${esc(f.content)}</td>
<td>${esc(f.app_version||'')}</td>
<td>${esc(f.language||'')}</td>
<td>${esc(f.status||'')}</td>
<td>${esc(f.created_at||'')}</td>
<td> <td>
<button type="button" class="secondary btn-sm" data-fdone="${esc(f.id)}">完成</button> <button type="button" class="secondary btn-sm" data-fdone="${esc(f.id)}">完成</button>
<button type="button" class="secondary btn-sm" data-fign="${esc(f.id)}">忽略</button> <button type="button" class="secondary btn-sm" data-fign="${esc(f.id)}">忽略</button>
</td></tr>`).join('') : '<tr><td colspan="7">暂无反馈</td></tr>'} </td></tr>`).join('') : '<tr><td colspan="9">暂无反馈</td></tr>'}
</tbody> </tbody>
</table>`; </table>`;
$('fbReload').onclick = () => pages.sparkFeedbacks();
$('fbAuthType').onchange = () => pages.sparkFeedbacks();
$('content').querySelectorAll('[data-fdone]').forEach((btn)=>{ $('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(); }; btn.onclick = async () => { await api('/spark/feedbacks/'+btn.dataset.fdone,{method:'PATCH',body:JSON.stringify({status:'done'})}); pages.sparkFeedbacks(); };
}); });

View File

@ -77,6 +77,7 @@
- Spark Messenger 全页面接入检查 - Spark Messenger 全页面接入检查
<<<<<<< HEAD
## 14:48 — 修复 make lint / make test ## 14:48 — 修复 make lint / make test
### 概要 ### 概要
@ -120,3 +121,39 @@
```bash ```bash
make install-git-hooks # 本机只需一次 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: 意见反馈支持登录与游客分流)

View File

@ -22,11 +22,18 @@ type LegalBundle struct {
Terms LegalDocument `json:"terms"` Terms LegalDocument `json:"terms"`
} }
const (
FeedbackAuthLogin = "login"
FeedbackAuthGuest = "guest"
)
// Feedback is one client feedback row. // Feedback is one client feedback row.
type Feedback struct { type Feedback struct {
ID int64 `json:"id"` ID int64 `json:"id"`
UserID *int64 `json:"user_id,omitempty"` 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"` Content string `json:"content"`
AppVersion string `json:"app_version"` AppVersion string `json:"app_version"`
Language string `json:"language"` Language string `json:"language"`

View File

@ -20,7 +20,7 @@ type Repository interface {
GetLegal(ctx context.Context, lang string) (privacy, terms *entity.LegalDocument, err error) GetLegal(ctx context.Context, lang string) (privacy, terms *entity.LegalDocument, err error)
UpsertLegal(ctx context.Context, d *entity.LegalDocument) error UpsertLegal(ctx context.Context, d *entity.LegalDocument) error
InsertFeedback(ctx context.Context, f *entity.Feedback) 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 UpdateFeedbackStatus(ctx context.Context, id int64, status string) error
ListPlans(ctx context.Context, lang string) ([]entity.SubscriptionPlan, error) ListPlans(ctx context.Context, lang string) ([]entity.SubscriptionPlan, error)
ListPlansAdmin(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 } func (s *Service) Ready() bool { return s != nil }
type FeedbackInput struct { 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"` 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"` 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"` 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 { if err := validate.StructLang(reqctx.Lang(ctx), &in); err != nil {
return nil, err 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{ f := &entity.Feedback{
Email: strings.TrimSpace(strings.ToLower(in.Email)), Email: email, Contact: contact, Content: strings.TrimSpace(in.Content),
Content: strings.TrimSpace(in.Content),
AppVersion: strings.TrimSpace(in.AppVersion), AppVersion: strings.TrimSpace(in.AppVersion),
Language: normalizeLang(reqctx.Language(ctx)), Language: normalizeLang(reqctx.Language(ctx)), Status: "open", CreatedAt: time.Now(),
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 { if rc := reqctx.From(ctx); rc != nil && rc.UserID > 0 {
uid := rc.UserID uid := rc.UserID
f.UserID = &uid 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 s.repo != nil {
if err := s.repo.InsertFeedback(ctx, f); err != 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 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 { if s.repo == nil {
return nil, 0, 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 { func (s *Service) UpdateFeedbackStatus(ctx context.Context, id int64, status string) error {

View File

@ -1,6 +1,7 @@
package logic package logic
import ( import (
<<<<<<< HEAD
"context" "context"
"testing" "testing"
@ -132,6 +133,12 @@ func (s stubOTP) AuthenticateEmail(context.Context, string, string, *userentity.
func ctxLang(lang string) context.Context { func ctxLang(lang string) context.Context {
return reqctx.With(context.Background(), &reqctx.RequestContext{Language: lang}) return reqctx.With(context.Background(), &reqctx.RequestContext{Language: lang})
} }
=======
"testing"
"spark-server/internal/module/client/entity"
)
>>>>>>> c844c07 (feat: 意见反馈支持登录与游客分流)
func TestLegalPlansFeedbackFallback(t *testing.T) { func TestLegalPlansFeedbackFallback(t *testing.T) {
svc := NewService(nil, nil, nil) svc := NewService(nil, nil, nil)
@ -143,8 +150,8 @@ func TestLegalPlansFeedbackFallback(t *testing.T) {
if err != nil || len(plans.Plans) != 4 { if err != nil || len(plans.Plans) != 4 {
t.Fatalf("plans %+v err=%v", plans, err) t.Fatalf("plans %+v err=%v", plans, err)
} }
fb, err := svc.SubmitFeedback(t.Context(), FeedbackInput{Content: "界面很好", Email: "a@b.com"}) fb, err := svc.SubmitFeedback(t.Context(), FeedbackInput{Content: "界面很好", Contact: "a@b.com"})
if err != nil || fb.ID == 0 { if err != nil || fb.ID == 0 || fb.AuthType != entity.FeedbackAuthGuest {
t.Fatalf("feedback %+v err=%v", fb, err) t.Fatalf("feedback %+v err=%v", fb, err)
} }
} }

View File

@ -35,7 +35,9 @@ func (legalRow) TableName() string { return "t_legal_documents" }
type feedbackRow struct { type feedbackRow struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement"` ID int64 `gorm:"column:id;primaryKey;autoIncrement"`
UserID *int64 `gorm:"column:user_id"` UserID *int64 `gorm:"column:user_id"`
AuthType string `gorm:"column:auth_type"`
Email string `gorm:"column:email"` Email string `gorm:"column:email"`
Contact string `gorm:"column:contact"`
Content string `gorm:"column:content"` Content string `gorm:"column:content"`
AppVersion string `gorm:"column:app_version"` AppVersion string `gorm:"column:app_version"`
Language string `gorm:"column:language"` Language string `gorm:"column:language"`
@ -110,31 +112,45 @@ func (r *Repo) InsertFeedback(ctx context.Context, f *entity.Feedback) error {
if status == "" { if status == "" {
status = "open" status = "open"
} }
authType := f.AuthType
if authType == "" {
authType = entity.FeedbackAuthGuest
}
row := feedbackRow{ row := feedbackRow{
UserID: f.UserID, Email: f.Email, Content: f.Content, UserID: f.UserID, AuthType: authType, Email: f.Email, Contact: f.Contact,
AppVersion: f.AppVersion, Language: f.Language, Status: status, CreatedAt: f.CreatedAt, 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 { if err := r.db.WithContext(ctx).Create(&row).Error; err != nil {
return err return err
} }
f.ID = row.ID f.ID = row.ID
f.Status = status f.Status = status
f.AuthType = authType
return nil 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 { if page < 0 {
page = 0 page = 0
} }
if size <= 0 || size > 200 { if size <= 0 || size > 200 {
size = 50 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 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 return nil, 0, err
} }
var rows []feedbackRow 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 { if err != nil {
return nil, 0, err return nil, 0, err
} }
@ -142,8 +158,9 @@ func (r *Repo) ListFeedback(ctx context.Context, page, size int) ([]entity.Feedb
for i := range rows { for i := range rows {
row := &rows[i] row := &rows[i]
out = append(out, entity.Feedback{ out = append(out, entity.Feedback{
ID: row.ID, UserID: row.UserID, Email: row.Email, Content: row.Content, ID: row.ID, UserID: row.UserID, AuthType: row.AuthType, Email: row.Email,
AppVersion: row.AppVersion, Language: row.Language, Status: row.Status, CreatedAt: row.CreatedAt, Contact: row.Contact, Content: row.Content, AppVersion: row.AppVersion,
Language: row.Language, Status: row.Status, CreatedAt: row.CreatedAt,
}) })
} }
return out, total, nil return out, total, nil

View File

@ -9,6 +9,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
authlogic "spark-server/internal/module/auth/logic" authlogic "spark-server/internal/module/auth/logic"
authentity "spark-server/internal/module/auth/entity"
"spark-server/internal/pkg/errs" "spark-server/internal/pkg/errs"
"spark-server/internal/pkg/ratelimit" "spark-server/internal/pkg/ratelimit"
"spark-server/internal/pkg/reqctx" "spark-server/internal/pkg/reqctx"
@ -27,7 +28,17 @@ const (
func Auth(svc authlogic.Service) gin.HandlerFunc { func Auth(svc authlogic.Service) gin.HandlerFunc {
return func(c *gin.Context) { 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() c.Next()
return return
} }
@ -45,16 +56,7 @@ func Auth(svc authlogic.Service) gin.HandlerFunc {
abortAuth(c, err) abortAuth(c, err)
return return
} }
rc := reqctx.From(c.Request.Context()) applyPrincipal(c, principal)
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
c.Next() c.Next()
} }
} }
@ -78,7 +80,7 @@ func AuthRateLimit(l *ratelimit.Limiter) gin.HandlerFunc {
func authLimitPath(path string) bool { func authLimitPath(path string) bool {
switch path { switch path {
case "/v1/auth/login", "/v1/auth/token/refresh", "/v1/sms/codes", 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 return true
default: default:
return false return false
@ -123,6 +125,48 @@ func matchDeviceHeader(raw string, want uint64) error {
return nil 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) { func abortAuth(c *gin.Context, err error) {
c.Abort() c.Abort()
if clientGone(c, err) { if clientGone(c, err) {

View File

@ -779,12 +779,15 @@ paths:
post: post:
tags: [client] tags: [client]
security: [] security: []
summary: 提交意见反馈 summary: 提交意见反馈(登录可选)
description: | 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 operationId: submitFeedback
parameters: parameters:
- $ref: "#/components/parameters/X-Language" - $ref: "#/components/parameters/X-Language"
- $ref: "#/components/parameters/X-Device-ID"
requestBody: requestBody:
required: true required: true
content: content:
@ -794,11 +797,17 @@ paths:
required: [content] required: [content]
properties: properties:
content: { type: string, minLength: 2, maxLength: 2000 } 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 } app_version: { type: string }
responses: responses:
"200": "200":
description: data 为反馈记录(含 id) description: data 为反馈记录(含 id、auth_type、contact)
content: content:
application/json: application/json:
schema: schema:

View File

@ -3,6 +3,7 @@ package adminhandler
import ( import (
"net/http" "net/http"
"strconv" "strconv"
"strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@ -90,12 +91,13 @@ func (h *ClientHandler) ListFeedback(c *gin.Context) {
} }
page, _ := strconv.Atoi(c.DefaultQuery("page", "0")) page, _ := strconv.Atoi(c.DefaultQuery("page", "0"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "50")) 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 { if err != nil {
Fail(c, http.StatusInternalServerError, err.Error()) Fail(c, http.StatusInternalServerError, err.Error())
return 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) { func (h *ClientHandler) PatchFeedback(c *gin.Context) {

View File

@ -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);