Compare commits
9 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
f2dfa09d97 | 2 days ago |
|
|
ed73e96376 | 7 days ago |
|
|
4a6d9c6cae | 2 weeks ago |
|
|
4356e7e997 | 2 weeks ago |
|
|
10779843e9 | 2 weeks ago |
|
|
c898acbf07 | 2 weeks ago |
|
|
48c47657ef | 2 weeks ago |
|
|
229ed2254b | 2 weeks ago |
|
|
803bfbc9ee | 2 weeks ago |
54 changed files with 2034 additions and 493 deletions
@ -0,0 +1,298 @@ |
|||||
|
<template> |
||||
|
<div class="space-y-5"> |
||||
|
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-4"> |
||||
|
<div class="flex items-end gap-3 flex-wrap"> |
||||
|
<div class="form-control"> |
||||
|
<label class="label py-1"><span class="label-text text-xs text-slate-500">应用 *</span></label> |
||||
|
<select v-model="qApp" class="select select-bordered select-sm" style="min-width:180px;" @change="onAppChange"> |
||||
|
<option v-if="!appNames.length" value="">无可用应用</option> |
||||
|
<option v-for="n in appNames" :key="n" :value="n">{{ n }}</option> |
||||
|
</select> |
||||
|
</div> |
||||
|
<div class="form-control"> |
||||
|
<label class="label py-1"><span class="label-text text-xs text-slate-500">区域 *</span></label> |
||||
|
<select v-model="qRegion" class="select select-bordered select-sm" style="min-width:130px;" @change="reload"> |
||||
|
<option v-if="!regionItems.length" value="">无可用区域</option> |
||||
|
<option v-for="a in regionItems" :key="a.id" :value="a.id">{{ regionLabel(a.region) }}</option> |
||||
|
</select> |
||||
|
</div> |
||||
|
<div class="form-control flex-1" style="min-width:220px;"> |
||||
|
<label class="label py-1"><span class="label-text text-xs text-slate-500">用户</span></label> |
||||
|
<input |
||||
|
v-model="q.user" type="text" class="input input-bordered input-sm w-full" |
||||
|
placeholder="UID(精确) / 手机号 / 邮箱 / 用户名 / 登录账号" @keydown.enter.prevent="reload" /> |
||||
|
</div> |
||||
|
<div class="form-control flex-1" style="min-width:180px;"> |
||||
|
<label class="label py-1"><span class="label-text text-xs text-slate-500">内容</span></label> |
||||
|
<input |
||||
|
v-model="q.content" type="text" class="input input-bordered input-sm w-full" |
||||
|
placeholder="反馈正文关键词" @keydown.enter.prevent="reload" /> |
||||
|
</div> |
||||
|
<div class="form-control"> |
||||
|
<label class="label py-1"><span class="label-text text-xs text-slate-500">提交起</span></label> |
||||
|
<input v-model="q.startDate" type="date" class="input input-bordered input-sm" @change="reload" /> |
||||
|
</div> |
||||
|
<div class="form-control"> |
||||
|
<label class="label py-1"><span class="label-text text-xs text-slate-500">提交止</span></label> |
||||
|
<input v-model="q.endDate" type="date" class="input input-bordered input-sm" @change="reload" /> |
||||
|
</div> |
||||
|
<button class="btn btn-primary btn-sm text-white" @click="reload">查询</button> |
||||
|
<button class="btn btn-ghost btn-sm" @click="resetFilters">重置</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="bg-white rounded-xl shadow-sm border border-slate-100"> |
||||
|
<div class="flex items-center justify-between px-5 py-3 border-b border-slate-100"> |
||||
|
<h3 class="text-sm font-semibold text-slate-700"> |
||||
|
用户反馈<span class="text-xs text-slate-400 font-normal ml-2">按提交时间倒序,点一行看全文</span> |
||||
|
</h3> |
||||
|
<div class="flex items-center gap-2"> |
||||
|
<span class="text-xs text-slate-500">共 {{ total }} 条</span> |
||||
|
<select v-model.number="q.pageSize" class="select select-bordered select-xs" @change="reload"> |
||||
|
<option :value="20">20/页</option> |
||||
|
<option :value="50">50/页</option> |
||||
|
<option :value="100">100/页</option> |
||||
|
</select> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="overflow-x-auto"> |
||||
|
<table class="table table-zebra text-sm"> |
||||
|
<thead> |
||||
|
<tr> |
||||
|
<th>时间</th><th>用户</th><th>内容</th><th>版本</th><th>机型</th><th>渠道</th> |
||||
|
</tr> |
||||
|
</thead> |
||||
|
<tbody> |
||||
|
<tr v-if="loading"><td colspan="6" class="text-center py-10"><span class="loading loading-spinner loading-sm"></span></td></tr> |
||||
|
<tr v-else-if="!qRegion"><td colspan="6" class="text-center text-slate-400 py-10">请先选择应用与区域</td></tr> |
||||
|
<tr v-else-if="listError"><td colspan="6" class="text-center text-red-400 py-10">{{ listError }}</td></tr> |
||||
|
<tr v-else-if="!rows.length"><td colspan="6" class="text-center text-slate-400 py-10">没有符合条件的反馈</td></tr> |
||||
|
<tr v-for="r in rows" v-else :key="r.id" class="hover cursor-pointer" @click="openDetail(r)"> |
||||
|
<td class="text-xs text-slate-500 whitespace-nowrap">{{ fmtTs(r.createtime) }}</td> |
||||
|
<td class="text-xs"> |
||||
|
<div class="font-medium text-slate-700">{{ r.name || '(未设置昵称)' }}</div> |
||||
|
<div class="text-slate-400 font-mono">{{ r.uid }}</div> |
||||
|
<div v-if="r.phone" class="text-slate-500">{{ r.phone }}</div> |
||||
|
<div v-if="r.mail" class="text-slate-500 truncate max-w-[180px]" :title="r.mail">{{ r.mail }}</div> |
||||
|
</td> |
||||
|
<td class="text-xs text-slate-700 max-w-[360px]"> |
||||
|
<div class="line-clamp-2 whitespace-pre-wrap break-words">{{ r.message }}</div> |
||||
|
</td> |
||||
|
<td class="text-xs text-slate-500 whitespace-nowrap">{{ r.version || '-' }}</td> |
||||
|
<td class="text-xs text-slate-500 max-w-[140px] truncate" :title="r.phonemodel">{{ r.phonemodel || '-' }}</td> |
||||
|
<td class="text-xs text-slate-500 whitespace-nowrap">{{ channelName(r.channel) }}</td> |
||||
|
</tr> |
||||
|
</tbody> |
||||
|
</table> |
||||
|
</div> |
||||
|
<div class="flex items-center justify-between px-5 py-3 border-t border-slate-100"> |
||||
|
<span class="text-xs text-slate-500">{{ pageInfo }}</span> |
||||
|
<div class="flex items-center gap-2"> |
||||
|
<button class="btn btn-ghost btn-xs" :disabled="q.page <= 0 || loading" @click="changePage(-1)">上一页</button> |
||||
|
<span class="text-xs text-slate-500">第 {{ q.page + 1 }} / {{ totalPages }} 页</span> |
||||
|
<button class="btn btn-ghost btn-xs" :disabled="q.page + 1 >= totalPages || loading" @click="changePage(1)">下一页</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<dialog ref="detailModal" class="modal"> |
||||
|
<div class="modal-box max-w-2xl"> |
||||
|
<h3 class="font-semibold text-slate-800">反馈全文</h3> |
||||
|
<div v-if="current" class="mt-3 space-y-3"> |
||||
|
<div class="text-xs text-slate-500 flex flex-wrap gap-x-4 gap-y-1"> |
||||
|
<span>{{ fmtTs(current.createtime) }}</span> |
||||
|
<span>{{ current.name || '(未设置昵称)' }}</span> |
||||
|
<span class="font-mono">{{ current.uid }}</span> |
||||
|
<span v-if="current.phone">{{ current.phone }}</span> |
||||
|
<span v-if="current.mail">{{ current.mail }}</span> |
||||
|
<span v-if="current.account">账号 {{ current.account }}</span> |
||||
|
<span>版本 {{ current.version || '-' }}</span> |
||||
|
<span>{{ current.phonemodel || '-' }}</span> |
||||
|
<span>{{ channelName(current.channel) }}</span> |
||||
|
</div> |
||||
|
<div class="text-sm text-slate-800 whitespace-pre-wrap break-words bg-slate-50 rounded-lg p-4">{{ current.message }}</div> |
||||
|
</div> |
||||
|
<div class="modal-action"> |
||||
|
<button class="btn btn-sm" @click="closeDetail">关闭</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
<form method="dialog" class="modal-backdrop"><button>close</button></form> |
||||
|
</dialog> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<script setup lang="ts"> |
||||
|
import { ref, reactive, computed, onMounted } from 'vue' |
||||
|
import { useApi } from '~/composables/useApi' |
||||
|
import { useAuthStore } from '~/stores/auth' |
||||
|
import { regionLabel } from '~/utils/regions' |
||||
|
|
||||
|
definePageMeta({ title: '用户反馈' }) |
||||
|
|
||||
|
interface AppItem { |
||||
|
id: number |
||||
|
name: string |
||||
|
app_name?: string |
||||
|
region: string |
||||
|
enabled?: boolean |
||||
|
} |
||||
|
interface FeedbackRow { |
||||
|
id: number |
||||
|
uid: string |
||||
|
message: string |
||||
|
version: string |
||||
|
phonemodel: string |
||||
|
channel: number |
||||
|
createtime: number |
||||
|
name: string |
||||
|
phone: string |
||||
|
mail: string |
||||
|
account: string |
||||
|
} |
||||
|
|
||||
|
const CHANNELS: Record<number, string> = { |
||||
|
1: '华为', 2: '小米', 3: 'OPPO', 4: 'VIVO', 5: '荣耀', |
||||
|
100: '官方包', 101: 'App Store', 102: 'Google Play', |
||||
|
} |
||||
|
function channelName(v: number) { |
||||
|
if (!v) return '-' |
||||
|
return CHANNELS[v] ?? '渠道' + v |
||||
|
} |
||||
|
|
||||
|
const { webApi, consoleApi } = useApi() |
||||
|
const auth = useAuthStore() |
||||
|
|
||||
|
const apps = ref<AppItem[]>([]) |
||||
|
const qApp = ref('') |
||||
|
const qRegion = ref<number | ''>('') |
||||
|
const rows = ref<FeedbackRow[]>([]) |
||||
|
const total = ref(0) |
||||
|
const loading = ref(false) |
||||
|
const listError = ref('') |
||||
|
const q = reactive({ user: '', content: '', startDate: '', endDate: '', page: 0, pageSize: 20 }) |
||||
|
const current = ref<FeedbackRow | null>(null) |
||||
|
const detailModal = ref<HTMLDialogElement | null>(null) |
||||
|
|
||||
|
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / q.pageSize))) |
||||
|
const pageInfo = computed(() => { |
||||
|
if (!total.value) return '-' |
||||
|
const from = q.page * q.pageSize + 1 |
||||
|
return `第 ${from} - ${Math.min(from + rows.value.length - 1, total.value)} 条 / 共 ${total.value} 条` |
||||
|
}) |
||||
|
const appNames = computed(() => { |
||||
|
const names: string[] = [] |
||||
|
apps.value.forEach((a) => { |
||||
|
const n = a.app_name || a.name |
||||
|
if (n && !names.includes(n)) names.push(n) |
||||
|
}) |
||||
|
return names |
||||
|
}) |
||||
|
const regionItems = computed(() => apps.value.filter((a) => (a.app_name || a.name) === qApp.value)) |
||||
|
|
||||
|
function onAppChange() { |
||||
|
const items = regionItems.value |
||||
|
qRegion.value = items.length ? items[0].id : '' |
||||
|
reload() |
||||
|
} |
||||
|
function fmtTs(ts: number) { |
||||
|
if (!ts) return '-' |
||||
|
return new Date(ts * 1000).toLocaleString() |
||||
|
} |
||||
|
function dateToUnix(s: string, endOfDay: boolean): number { |
||||
|
if (!s) return 0 |
||||
|
const d = new Date(s + 'T00:00:00') |
||||
|
if (isNaN(d.getTime())) return 0 |
||||
|
return Math.floor(d.getTime() / 1000) + (endOfDay ? 86400 : 0) |
||||
|
} |
||||
|
|
||||
|
async function loadList() { |
||||
|
if (!qApp.value || !qRegion.value) { |
||||
|
rows.value = [] |
||||
|
total.value = 0 |
||||
|
return |
||||
|
} |
||||
|
auth.setCurrentApp(Number(qRegion.value)) |
||||
|
loading.value = true |
||||
|
listError.value = '' |
||||
|
try { |
||||
|
const d = await webApi<{ items: FeedbackRow[]; total: number }>('getuserfeedbacks', { |
||||
|
user: q.user.trim(), |
||||
|
content: q.content.trim(), |
||||
|
start: dateToUnix(q.startDate, false), |
||||
|
end: dateToUnix(q.endDate, true), |
||||
|
page: q.page, |
||||
|
page_size: q.pageSize, |
||||
|
}) |
||||
|
rows.value = d?.items ?? [] |
||||
|
total.value = d?.total ?? 0 |
||||
|
} catch (e: any) { |
||||
|
listError.value = e?.message || '查询失败' |
||||
|
rows.value = [] |
||||
|
total.value = 0 |
||||
|
} finally { |
||||
|
loading.value = false |
||||
|
} |
||||
|
} |
||||
|
function reload() { |
||||
|
q.page = 0 |
||||
|
loadList() |
||||
|
} |
||||
|
function changePage(d: number) { |
||||
|
const next = q.page + d |
||||
|
if (next < 0 || next >= totalPages.value) return |
||||
|
q.page = next |
||||
|
loadList() |
||||
|
} |
||||
|
function resetFilters() { |
||||
|
q.user = '' |
||||
|
q.content = '' |
||||
|
q.startDate = '' |
||||
|
q.endDate = '' |
||||
|
reload() |
||||
|
} |
||||
|
function openDetail(row: FeedbackRow) { |
||||
|
current.value = row |
||||
|
detailModal.value?.showModal() |
||||
|
} |
||||
|
function closeDetail() { |
||||
|
detailModal.value?.close() |
||||
|
} |
||||
|
|
||||
|
function buildAppSelect() { |
||||
|
const names = appNames.value |
||||
|
if (!names.length) { |
||||
|
qApp.value = '' |
||||
|
qRegion.value = '' |
||||
|
return |
||||
|
} |
||||
|
qApp.value = names[0] |
||||
|
const items = regionItems.value |
||||
|
qRegion.value = items.length ? items[0].id : '' |
||||
|
} |
||||
|
|
||||
|
async function loadApps() { |
||||
|
if (!auth.isAdmin) { |
||||
|
try { |
||||
|
const d = await webApi<{ apps: AppItem[] }>('getmyapps', {}) |
||||
|
apps.value = d?.apps ?? [] |
||||
|
} catch { |
||||
|
apps.value = (auth.appbinds || []).map((a) => ({ |
||||
|
id: a.id, name: a.name, app_name: a.app_name, region: a.region, enabled: true, |
||||
|
})) |
||||
|
} |
||||
|
buildAppSelect() |
||||
|
loadList() |
||||
|
return |
||||
|
} |
||||
|
try { |
||||
|
const list = await consoleApi<AppItem[]>('apps/list', {}) |
||||
|
apps.value = (list || []).filter((a) => a.enabled) |
||||
|
} catch { |
||||
|
apps.value = [] |
||||
|
} |
||||
|
buildAppSelect() |
||||
|
loadList() |
||||
|
} |
||||
|
|
||||
|
onMounted(loadApps) |
||||
|
</script> |
||||
File diff suppressed because it is too large
@ -0,0 +1,25 @@ |
|||||
|
package wordfilter |
||||
|
|
||||
|
import ( |
||||
|
_ "embed" |
||||
|
"strings" |
||||
|
) |
||||
|
|
||||
|
// 内置词库随二进制打进 home。部署配置里的 WorldFile 默认全部注释,
|
||||
|
// 不内置的话改昵称时词树是空的,Validate 恒通过。
|
||||
|
//
|
||||
|
//go:embed wordfilter.txt
|
||||
|
var builtinWordDict string |
||||
|
|
||||
|
func (s *Sys) loadBuiltin() (int, error) { |
||||
|
n := 0 |
||||
|
for _, line := range strings.Split(builtinWordDict, "\n") { |
||||
|
line = strings.TrimSpace(line) |
||||
|
if line == "" { |
||||
|
continue |
||||
|
} |
||||
|
s.trie.Add(line) |
||||
|
n++ |
||||
|
} |
||||
|
return n, nil |
||||
|
} |
||||
@ -0,0 +1,116 @@ |
|||||
|
package console |
||||
|
|
||||
|
import ( |
||||
|
"strings" |
||||
|
|
||||
|
"yunyan/comm" |
||||
|
"yunyan/pb" |
||||
|
|
||||
|
"github.com/gin-gonic/gin" |
||||
|
) |
||||
|
|
||||
|
// userFeedbackListReq 用户反馈列表筛选。
|
||||
|
//
|
||||
|
// User 一栏多用:UID 精确,手机号 / 邮箱 / 用户名 / 登录账号模糊。
|
||||
|
// 客服转来的线索形态不定,让人先选「按什么查」只是多一步。
|
||||
|
// Content 只搜反馈正文。时间区间是 [start, end),结束日由前端按当天 24:00 换算。
|
||||
|
type userFeedbackListReq struct { |
||||
|
User string `json:"user"` |
||||
|
Content string `json:"content"` |
||||
|
Start int64 `json:"start"` |
||||
|
End int64 `json:"end"` |
||||
|
Page int `json:"page"` |
||||
|
PageSize int `json:"page_size"` |
||||
|
} |
||||
|
|
||||
|
// userFeedbackItem 列表行。用户字段来自提交时仍在库里的 user 行;
|
||||
|
// 账号已注销删掉时这几列为空,uid 仍在。
|
||||
|
type userFeedbackItem struct { |
||||
|
Id uint64 `json:"id" gorm:"column:id"` |
||||
|
Uid string `json:"uid" gorm:"column:uid"` |
||||
|
Message string `json:"message" gorm:"column:message"` |
||||
|
Version string `json:"version" gorm:"column:version"` |
||||
|
Phonemodel string `json:"phonemodel" gorm:"column:phonemodel"` |
||||
|
Channel int32 `json:"channel" gorm:"column:channel"` |
||||
|
Createtime int64 `json:"createtime" gorm:"column:createtime"` |
||||
|
Name string `json:"name" gorm:"column:name"` |
||||
|
Phone string `json:"phone" gorm:"column:phone"` |
||||
|
Mail string `json:"mail" gorm:"column:mail"` |
||||
|
Account string `json:"account" gorm:"column:account"` |
||||
|
} |
||||
|
|
||||
|
// buildUserFeedbackWhere 拼筛选条件。表别名 f = userfeedback,u = user。
|
||||
|
func buildUserFeedbackWhere(req *userFeedbackListReq) (string, []interface{}) { |
||||
|
conds := make([]string, 0, 4) |
||||
|
args := make([]interface{}, 0, 8) |
||||
|
if kw := strings.TrimSpace(req.User); kw != "" { |
||||
|
like := "%" + kw + "%" |
||||
|
conds = append(conds, "(f.uid = ? OR u.phone LIKE ? OR u.mail LIKE ? OR u.name LIKE ? OR u.account LIKE ?)") |
||||
|
args = append(args, kw, like, like, like, like) |
||||
|
} |
||||
|
if kw := strings.TrimSpace(req.Content); kw != "" { |
||||
|
conds = append(conds, "f.message LIKE ?") |
||||
|
args = append(args, "%"+kw+"%") |
||||
|
} |
||||
|
if req.Start > 0 { |
||||
|
conds = append(conds, "f.createtime >= ?") |
||||
|
args = append(args, req.Start) |
||||
|
} |
||||
|
if req.End > 0 { |
||||
|
conds = append(conds, "f.createtime < ?") |
||||
|
args = append(args, req.End) |
||||
|
} |
||||
|
if len(conds) == 0 { |
||||
|
return "1=1", args |
||||
|
} |
||||
|
return strings.Join(conds, " AND "), args |
||||
|
} |
||||
|
|
||||
|
// getUserFeedbacks 所选应用业务库里的用户反馈,按提交时间倒序。
|
||||
|
func (this *serverComp) getUserFeedbacks(c *gin.Context) { |
||||
|
appId := parseAppId(c.GetHeader("X-App-Id")) |
||||
|
if !this.requireAppScope(c, appId) { |
||||
|
return |
||||
|
} |
||||
|
conn, err := this.module.registry.getServiceDB(appId) |
||||
|
if err != nil { |
||||
|
writeErr(c, pb.ErrorCode_DBError, err.Error()) |
||||
|
return |
||||
|
} |
||||
|
req := &userFeedbackListReq{} |
||||
|
_ = c.ShouldBindJSON(req) |
||||
|
if req.PageSize <= 0 { |
||||
|
req.PageSize = 20 |
||||
|
} |
||||
|
if req.PageSize > userListPageMax { |
||||
|
req.PageSize = userListPageMax |
||||
|
} |
||||
|
if req.Page < 0 { |
||||
|
req.Page = 0 |
||||
|
} |
||||
|
|
||||
|
where, args := buildUserFeedbackWhere(req) |
||||
|
base := conn.Table(comm.TableUserFeedback+" AS f"). |
||||
|
Joins("LEFT JOIN `"+comm.TableUser+"` AS u ON u.uid = f.uid"). |
||||
|
Where(where, args...) |
||||
|
var total int64 |
||||
|
if err := base.Count(&total).Error; err != nil { |
||||
|
writeErr(c, pb.ErrorCode_DBError, err.Error()) |
||||
|
return |
||||
|
} |
||||
|
rows := make([]*userFeedbackItem, 0, req.PageSize) |
||||
|
if total > 0 { |
||||
|
// Count 已经用过这条链,再查要重新起一条,否则 Limit 会叠在已执行的语句上。
|
||||
|
if err := conn.Table(comm.TableUserFeedback+" AS f"). |
||||
|
Select("f.id, f.uid, f.message, f.version, f.phonemodel, f.channel, f.createtime, u.name, u.phone, u.mail, u.account"). |
||||
|
Joins("LEFT JOIN `"+comm.TableUser+"` AS u ON u.uid = f.uid"). |
||||
|
Where(where, args...). |
||||
|
Order("f.createtime DESC, f.id DESC"). |
||||
|
Limit(req.PageSize).Offset(req.Page * req.PageSize). |
||||
|
Find(&rows).Error; err != nil { |
||||
|
writeErr(c, pb.ErrorCode_DBError, err.Error()) |
||||
|
return |
||||
|
} |
||||
|
} |
||||
|
writeOK(c, gin.H{"items": rows, "total": total, "page": req.Page, "page_size": req.PageSize}) |
||||
|
} |
||||
@ -0,0 +1,28 @@ |
|||||
|
package console |
||||
|
|
||||
|
import ( |
||||
|
"strings" |
||||
|
"testing" |
||||
|
) |
||||
|
|
||||
|
func TestBuildUserFeedbackWhere(t *testing.T) { |
||||
|
where, args := buildUserFeedbackWhere(&userFeedbackListReq{}) |
||||
|
if where != "1=1" || len(args) != 0 { |
||||
|
t.Fatalf("empty: where %q args %v", where, args) |
||||
|
} |
||||
|
|
||||
|
where, args = buildUserFeedbackWhere(&userFeedbackListReq{ |
||||
|
User: "138", Content: "耳机", Start: 10, End: 20, |
||||
|
}) |
||||
|
for _, sub := range []string{"f.uid = ?", "u.phone LIKE ?", "f.message LIKE ?", "f.createtime >= ?", "f.createtime < ?"} { |
||||
|
if !strings.Contains(where, sub) { |
||||
|
t.Fatalf("where %q missing %q", where, sub) |
||||
|
} |
||||
|
} |
||||
|
if len(args) != 8 { |
||||
|
t.Fatalf("args len = %d, want 8: %v", len(args), args) |
||||
|
} |
||||
|
if args[0] != "138" || args[1] != "%138%" || args[5] != "%耳机%" || args[6] != int64(10) || args[7] != int64(20) { |
||||
|
t.Fatalf("args = %v", args) |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,23 @@ |
|||||
|
package user |
||||
|
|
||||
|
import ( |
||||
|
"strings" |
||||
|
"testing" |
||||
|
) |
||||
|
|
||||
|
func TestNormalizeFeedbackMessage(t *testing.T) { |
||||
|
if got, reason := normalizeFeedbackMessage(" 耳机连不上 "); reason != "" || got != "耳机连不上" { |
||||
|
t.Fatalf("trim: got %q reason %q", got, reason) |
||||
|
} |
||||
|
if _, reason := normalizeFeedbackMessage(" "); reason == "" { |
||||
|
t.Fatal("blank should be rejected") |
||||
|
} |
||||
|
long := strings.Repeat("字", feedbackMessageMax+1) |
||||
|
if _, reason := normalizeFeedbackMessage(long); reason == "" { |
||||
|
t.Fatal("overlong should be rejected") |
||||
|
} |
||||
|
edge := strings.Repeat("a", feedbackMessageMax) |
||||
|
if got, reason := normalizeFeedbackMessage(edge); reason != "" || got != edge { |
||||
|
t.Fatalf("max length should pass, reason %q len %d", reason, len(got)) |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,92 @@ |
|||||
|
package user |
||||
|
|
||||
|
import ( |
||||
|
"errors" |
||||
|
"time" |
||||
|
|
||||
|
"yunyan/comm" |
||||
|
"yunyan/lego/core" |
||||
|
"yunyan/lego/core/cbase" |
||||
|
"yunyan/lego/sys/log" |
||||
|
"yunyan/lego/sys/mysql" |
||||
|
) |
||||
|
|
||||
|
// dbUserFeedback App 用户反馈。本地 gorm 结构,不进 pb:
|
||||
|
// 客户端只提交、不回读;后台列表由 console 直连业务库查询。
|
||||
|
//
|
||||
|
// version / phonemodel / channel 是提交当时从 user 行抄下来的快照。
|
||||
|
// 用户之后升级或换机,这条记录仍停在提交时的环境上,排查「哪一版出的问题」靠它。
|
||||
|
type dbUserFeedback struct { |
||||
|
Id uint64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"` |
||||
|
Uid string `gorm:"column:uid;size:50;index" json:"uid"` |
||||
|
Message string `gorm:"column:message;size:2000" json:"message"` |
||||
|
Version string `gorm:"column:version;size:32" json:"version"` |
||||
|
Phonemodel string `gorm:"column:phonemodel;size:64" json:"phonemodel"` |
||||
|
Channel int32 `gorm:"column:channel" json:"channel"` |
||||
|
Createtime int64 `gorm:"column:createtime;index" json:"createtime"` |
||||
|
} |
||||
|
|
||||
|
type modelFeedbackComp struct { |
||||
|
cbase.ModuleCompBase |
||||
|
module *User |
||||
|
} |
||||
|
|
||||
|
func (this *modelFeedbackComp) Init(service core.IService, module core.IModule, comp core.IModuleComp, opt core.IModuleOptions) (err error) { |
||||
|
this.ModuleCompBase.Init(service, module, comp, opt) |
||||
|
this.module = module.(*User) |
||||
|
if err = mysql.CreateTable(comm.TableUserFeedback, &dbUserFeedback{}); err != nil { |
||||
|
this.module.Errorln(err) |
||||
|
} |
||||
|
// 后台列表按手机号/昵称筛时要 JOIN user。user 表多半是老库的 utf8mb4_general_ci,
|
||||
|
// 新表按服务器默认建会是 utf8mb4_0900_ai_ci,联表直接 1267。对齐后再查。
|
||||
|
this.alignCollation() |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
func (this *modelFeedbackComp) alignCollation() { |
||||
|
target := this.tableCollation(comm.TableUser) |
||||
|
charset, ok := collationCharset(target) |
||||
|
if !ok { |
||||
|
this.module.Warn("用户反馈表排序规则对齐:user 表排序规则读不到或不合法,跳过", log.Field{Key: "collation", Value: target}) |
||||
|
return |
||||
|
} |
||||
|
cur := this.tableCollation(comm.TableUserFeedback) |
||||
|
if cur == "" || cur == target { |
||||
|
return |
||||
|
} |
||||
|
if res := mysql.Exec("ALTER TABLE `" + comm.TableUserFeedback + "` CONVERT TO CHARACTER SET " + charset + " COLLATE " + target); res.Error != nil { |
||||
|
this.module.Error("用户反馈表排序规则对齐失败", log.Field{Key: "from", Value: cur}, |
||||
|
log.Field{Key: "to", Value: target}, log.Field{Key: "err", Value: res.Error.Error()}) |
||||
|
return |
||||
|
} |
||||
|
this.module.Infof("用户反馈表排序规则对齐:%s → %s", cur, target) |
||||
|
} |
||||
|
|
||||
|
func (this *modelFeedbackComp) tableCollation(table string) string { |
||||
|
var coll string |
||||
|
res := mysql.Raw("SELECT TABLE_COLLATION FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?", table).Scan(&coll) |
||||
|
if res.Error != nil { |
||||
|
this.module.Warn("读表排序规则失败", log.Field{Key: "table", Value: table}, log.Field{Key: "err", Value: res.Error.Error()}) |
||||
|
return "" |
||||
|
} |
||||
|
return coll |
||||
|
} |
||||
|
|
||||
|
// insert 写入一条反馈。用户资料读不到时快照留空,内容照样落库——
|
||||
|
// 反馈本身比「附带的版本号」重要,不能因为查用户失败就把内容丢掉。
|
||||
|
func (this *modelFeedbackComp) insert(uid, message string) error { |
||||
|
row := &dbUserFeedback{ |
||||
|
Uid: uid, |
||||
|
Message: message, |
||||
|
Createtime: time.Now().Unix(), |
||||
|
} |
||||
|
if user, err := this.module.model.findforid(uid); err == nil && user != nil { |
||||
|
row.Version = user.Version |
||||
|
row.Phonemodel = user.Phonemodel |
||||
|
row.Channel = int32(user.Channel) |
||||
|
} else if err != nil && !errors.Is(err, mysql.ErrNoDocuments) { |
||||
|
this.module.Warn("用户反馈:读用户快照失败,仍写入内容", |
||||
|
log.Field{Key: "uid", Value: uid}, log.Field{Key: "err", Value: err.Error()}) |
||||
|
} |
||||
|
return mysql.Insert(comm.TableUserFeedback, row) |
||||
|
} |
||||
Loading…
Reference in new issue