Compare commits
9 Commits
main
...
along-test
| 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