Compare commits

..

No commits in common. "347dc191b7264f2bfa97d090de7e9765dedc0924" and "d5c0d81e22d3f16a51ac4112b5861fbefee87285" have entirely different histories.

10 changed files with 46 additions and 275 deletions

View File

@ -3133,54 +3133,26 @@ 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' + q); } catch (e) {
try { data = await api('/spark/feedbacks?page=0&size=50'); } catch (e) {
$('content').innerHTML = `<div class="note error">App反馈 API:${esc(e.message)}</div>`;
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 = `
<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>
<div class="note">SleepHeart 意见反馈 · <code>/api/admin/spark/feedbacks</code> · 客户端 <code>POST /v1/feedback</code> · 共 ${esc(data.total||0)} 条</div>
<table>
<thead><tr><th>ID</th><th>类型</th><th>身份/联系</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></tr></thead>
<tbody>
${list.length ? list.map((f)=>`<tr>
<td>${esc(f.id)}</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>${esc(f.id)}</td><td style="max-width:280px">${esc(f.content)}</td><td>${esc(f.email||'')}</td>
<td>${esc(f.language||'')}</td><td>${esc(f.status||'')}</td><td>${esc(f.created_at||'')}</td>
<td>
<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>
</td></tr>`).join('') : '<tr><td colspan="9">暂无反馈</td></tr>'}
</td></tr>`).join('') : '<tr><td colspan="7">暂无反馈</td></tr>'}
</tbody>
</table>`;
$('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(); };
});

View File

@ -77,7 +77,6 @@
- Spark Messenger 全页面接入检查
## 14:48 — 修复 make lint / make test
### 概要
@ -121,50 +120,3 @@
```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)
- 需部署并执行 migration 后生效
### 关联
- 用户需求:意见反馈分登录与未登录
## 16:25 — 反馈用例补登录路径断言
### 概要
- 补充游客缺 contact 失败、登录态写入 auth_type/uid、AppVersion Header 回填的单测断言
- auth.go import 顺序整理
### 验证
- `make test`: skipped(本机无 go)

View File

@ -22,18 +22,11 @@ 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"`
AuthType string `json:"auth_type"`
Email string `json:"email,omitempty"`
Contact string `json:"contact,omitempty"`
Email string `json:"email"`
Content string `json:"content"`
AppVersion string `json:"app_version"`
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)
UpsertLegal(ctx context.Context, d *entity.LegalDocument) error
InsertFeedback(ctx context.Context, f *entity.Feedback) error
ListFeedback(ctx context.Context, page, size int, authType string) ([]entity.Feedback, int64, error)
ListFeedback(ctx context.Context, page, size int) ([]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,7 +52,6 @@ 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"`
@ -100,36 +99,17 @@ 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: email, Contact: contact, Content: strings.TrimSpace(in.Content),
Email: strings.TrimSpace(strings.ToLower(in.Email)),
Content: strings.TrimSpace(in.Content),
AppVersion: strings.TrimSpace(in.AppVersion),
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)
}
Language: normalizeLang(reqctx.Language(ctx)),
Status: "open",
CreatedAt: time.Now(),
}
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 {
@ -141,32 +121,11 @@ func (s *Service) SubmitFeedback(ctx context.Context, in FeedbackInput) (*entity
return f, nil
}
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) {
func (s *Service) ListFeedbackAdmin(ctx context.Context, page, size int) ([]entity.Feedback, int64, error) {
if s.repo == nil {
return nil, 0, nil
}
authType = strings.TrimSpace(authType)
return s.repo.ListFeedback(ctx, page, size, authType)
return s.repo.ListFeedback(ctx, page, size)
}
func (s *Service) UpdateFeedbackStatus(ctx context.Context, id int64, status string) error {

View File

@ -47,17 +47,8 @@ func (s *stubClientRepo) InsertFeedback(_ context.Context, f *entity.Feedback) e
return nil
}
func (s *stubClientRepo) ListFeedback(_ context.Context, _, _ int, authType string) ([]entity.Feedback, int64, error) {
if authType == "" {
return s.feedback, int64(len(s.feedback)), nil
}
out := make([]entity.Feedback, 0)
for i := range s.feedback {
if s.feedback[i].AuthType == authType {
out = append(out, s.feedback[i])
}
}
return out, int64(len(out)), nil
func (s *stubClientRepo) ListFeedback(_ context.Context, _, _ int) ([]entity.Feedback, int64, error) {
return s.feedback, int64(len(s.feedback)), nil
}
func (s *stubClientRepo) UpdateFeedbackStatus(_ context.Context, id int64, status string) error {
@ -152,21 +143,10 @@ 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: "界面很好", Contact: "a@b.com"})
if err != nil || fb.ID == 0 || fb.AuthType != entity.FeedbackAuthGuest {
fb, err := svc.SubmitFeedback(t.Context(), FeedbackInput{Content: "界面很好", Email: "a@b.com"})
if err != nil || fb.ID == 0 {
t.Fatalf("feedback %+v err=%v", fb, err)
}
if _, err := svc.SubmitFeedback(t.Context(), FeedbackInput{Content: "缺联系方式"}); err == nil {
t.Fatal("guest without contact should fail")
}
ctx := reqctx.With(t.Context(), &reqctx.RequestContext{UserID: 42, AppVersion: "1.2.3"})
loginFB, err := svc.SubmitFeedback(ctx, FeedbackInput{Content: "登录用户反馈"})
if err != nil || loginFB.AuthType != entity.FeedbackAuthLogin || loginFB.UserID == nil || *loginFB.UserID != 42 {
t.Fatalf("login feedback %+v err=%v", loginFB, err)
}
if loginFB.AppVersion != "1.2.3" {
t.Fatalf("app_version from header want 1.2.3 got %q", loginFB.AppVersion)
}
}
func TestClientServiceAdminAndRepo(t *testing.T) {
@ -194,11 +174,11 @@ func TestClientServiceAdminAndRepo(t *testing.T) {
if err := svc.UpsertLegalAdmin(ctx, &entity.LegalDocument{Key: entity.DocPrivacy, Lang: "en", Title: "Privacy", Content: "body"}); err != nil {
t.Fatal(err)
}
list, total, err := svc.ListFeedbackAdmin(ctx, 0, 10, "")
list, total, err := svc.ListFeedbackAdmin(ctx, 0, 10)
if err != nil || total != 0 || len(list) != 0 {
t.Fatalf("feedback list %+v %d %v", list, total, err)
}
_, err = svc.SubmitFeedback(ctx, FeedbackInput{Content: "ok feedback", Contact: "guest@example.com"})
_, err = svc.SubmitFeedback(ctx, FeedbackInput{Content: "ok"})
if err != nil {
t.Fatal(err)
}

View File

@ -35,9 +35,7 @@ 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"`
@ -112,45 +110,31 @@ 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, AuthType: authType, Email: f.Email, Contact: f.Contact,
Content: f.Content, AppVersion: f.AppVersion, Language: f.Language,
Status: status, CreatedAt: f.CreatedAt,
UserID: f.UserID, Email: f.Email, 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, authType string) ([]entity.Feedback, int64, error) {
func (r *Repo) ListFeedback(ctx context.Context, page, size int) ([]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 := q.Count(&total).Error; err != nil {
if err := r.db.WithContext(ctx).Model(&feedbackRow{}).Count(&total).Error; err != nil {
return nil, 0, err
}
var rows []feedbackRow
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
err := r.db.WithContext(ctx).Order("id DESC").Offset(page * size).Limit(size).Find(&rows).Error
if err != nil {
return nil, 0, err
}
@ -158,9 +142,8 @@ func (r *Repo) ListFeedback(ctx context.Context, page, size int, authType string
for i := range rows {
row := &rows[i]
out = append(out, entity.Feedback{
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,
ID: row.ID, UserID: row.UserID, Email: row.Email, Content: row.Content,
AppVersion: row.AppVersion, Language: row.Language, Status: row.Status, CreatedAt: row.CreatedAt,
})
}
return out, total, nil

View File

@ -8,7 +8,6 @@ import (
"github.com/gin-gonic/gin"
authentity "spark-server/internal/module/auth/entity"
authlogic "spark-server/internal/module/auth/logic"
"spark-server/internal/pkg/errs"
"spark-server/internal/pkg/ratelimit"
@ -28,17 +27,7 @@ const (
func Auth(svc authlogic.Service) gin.HandlerFunc {
return func(c *gin.Context) {
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
}
}
if svc == nil || publicPath(c.Request.URL.Path) {
c.Next()
return
}
@ -56,7 +45,16 @@ func Auth(svc authlogic.Service) gin.HandlerFunc {
abortAuth(c, err)
return
}
applyPrincipal(c, principal)
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
c.Next()
}
}
@ -80,7 +78,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/feedback":
"/v1/auth/password-login", "/v1/auth/password":
return true
default:
return false
@ -125,48 +123,6 @@ 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) {

View File

@ -779,15 +779,12 @@ paths:
post:
tags: [client]
security: []
summary: 提交意见反馈(登录可选)
summary: 提交意见反馈
description: |
可选鉴权:带有效 `Authorization: Bearer` → `auth_type=login` 并写入 `user_id`;
无 token → `auth_type=guest`,Body 必填 `contact`(邮箱或手机)与 `content`。
兼容字段 `email`(游客未传 contact 时可用 email 作为 contact)。
可匿名提交。若带 Bearer 会关联 user_id。Body:`content` 必填;可选 `email`、`app_version`。
operationId: submitFeedback
parameters:
- $ref: "#/components/parameters/X-Language"
- $ref: "#/components/parameters/X-Device-ID"
requestBody:
required: true
content:
@ -797,17 +794,11 @@ paths:
required: [content]
properties:
content: { type: string, minLength: 2, maxLength: 2000 }
contact:
type: string
description: 游客必填;邮箱或手机号
email:
type: string
format: email
description: 兼容旧字段;游客可代替 contact
email: { type: string, format: email }
app_version: { type: string }
responses:
"200":
description: data 为反馈记录(含 id、auth_type、contact)
description: data 为反馈记录(含 id)
content:
application/json:
schema:

View File

@ -3,7 +3,6 @@ package adminhandler
import (
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
@ -91,13 +90,12 @@ func (h *ClientHandler) ListFeedback(c *gin.Context) {
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "0"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "50"))
authType := strings.TrimSpace(c.Query("auth_type"))
list, total, err := h.svc.ListFeedbackAdmin(c.Request.Context(), page, size, authType)
list, total, err := h.svc.ListFeedbackAdmin(c.Request.Context(), page, size)
if err != nil {
Fail(c, http.StatusInternalServerError, err.Error())
return
}
OK(c, gin.H{"list": list, "total": total, "page": page, "size": size, "auth_type": authType})
OK(c, gin.H{"list": list, "total": total, "page": page, "size": size})
}
func (h *ClientHandler) PatchFeedback(c *gin.Context) {

View File

@ -1,13 +0,0 @@
-- 意见反馈区分登录 / 游客,并增加游客联系方式。
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);