fix(build): 修正 .gitignore 的 log/ 规则误伤源码包,恢复 HEAD 可编译
问题:.gitignore 第 27 行的 `log/` 未锚定根目录,会匹配任意层级的 log 目录,把下面 4 个**源码包**一并忽略: - api/admin/base/log - api/admin/system/log - internal/service/admin/base/log - internal/service/admin/system/log 而引用它们的 internal/controller/admin/log.go 已在库中,于是 「已提交的代码 import 了不存在(未提交)的包」——干净检出后 `go build .` 直接失败: no required module provides package service.xpcool.com/api/admin/base/log 因为 service 仓库没有 CI 自动构建,这个问题一直没有暴露, 影响是:新克隆/新设备无法编译,任何干净检出构建都会失败。 修复: 1. 规则改为 `/log/`,只忽略仓库根目录下的运行日志输出目录 (config.dev.yaml 的 logger.path: "log"),不再误伤同名源码包; 2. 补交被忽略期间漏掉的 4 个 log 包,恢复 HEAD 完整可编译。
This commit is contained in:
parent
d136c23874
commit
9010592c9e
5
.gitignore
vendored
5
.gitignore
vendored
@ -24,7 +24,10 @@ bin
|
|||||||
.workbuddy/memory/*
|
.workbuddy/memory/*
|
||||||
!.workbuddy/memory/CHANGELOG.md
|
!.workbuddy/memory/CHANGELOG.md
|
||||||
# server runtime logs
|
# server runtime logs
|
||||||
log/
|
# 注意必须锚定到仓库根目录:写成 `log/` 会匹配任意层级的 log 目录,
|
||||||
|
# 把 api/admin/**/log、internal/service/admin/**/log 这些**源码包**一并忽略,
|
||||||
|
# 导致它们被静默漏提交(引用方 controller 已入库 → HEAD 编译不过)。
|
||||||
|
/log/
|
||||||
|
|
||||||
# 本地环境变量(含数据库口令),禁止提交
|
# 本地环境变量(含数据库口令),禁止提交
|
||||||
.env*
|
.env*
|
||||||
|
|||||||
40
api/admin/base/log/log.go
Normal file
40
api/admin/base/log/log.go
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
// Package admin_base_log 定义服务器日志监控接口契约。
|
||||||
|
//
|
||||||
|
// 路由前缀 /api/service/admin/base/log,对应 admin_menu 中的:
|
||||||
|
// - POST /api/service/admin/base/log/files —— monitor:log:view(查看日志文件列表)
|
||||||
|
// - POST /api/service/admin/base/log/tail —— monitor:log:tail(实时读取日志尾部)
|
||||||
|
package admin_base_log
|
||||||
|
|
||||||
|
import "github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
|
// LogFile 描述一个服务器日志文件。
|
||||||
|
type LogFile struct {
|
||||||
|
Name string `json:"name"` // 文件名
|
||||||
|
Path string `json:"path"` // 文件绝对路径
|
||||||
|
Size int64 `json:"size"` // 文件大小(字节)
|
||||||
|
ModTime string `json:"modTime"` // 最后修改时间
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogFilesReq 列出服务器日志文件。
|
||||||
|
type LogFilesReq struct {
|
||||||
|
g.Meta `path:"/base/log/files" method:"post" tags:"Admin/Base/Log" summary:"日志文件列表"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogFilesRes 是 LogFilesReq 的响应。
|
||||||
|
type LogFilesRes struct {
|
||||||
|
Dir string `json:"dir"` // 日志目录
|
||||||
|
Files []*LogFile `json:"files"` // 文件列表
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogTailReq 读取日志文件尾部,支持关键字过滤。
|
||||||
|
type LogTailReq struct {
|
||||||
|
g.Meta `path:"/base/log/tail" method:"post" tags:"Admin/Base/Log" summary:"读取日志尾部"`
|
||||||
|
File string `json:"file" v:"required#日志文件名不能为空"` // 日志文件名(不含目录)
|
||||||
|
Lines int `json:"lines" d:"200" v:"min:1|max:5000#行数必须在 1-5000 之间"`
|
||||||
|
Keyword string `json:"keyword"` // 关键字过滤,为空则不过滤
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogTailRes 是 LogTailReq 的响应。
|
||||||
|
type LogTailRes struct {
|
||||||
|
Lines []string `json:"lines"` // 日志行
|
||||||
|
}
|
||||||
51
api/admin/system/log/log.go
Normal file
51
api/admin/system/log/log.go
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
// Package admin_system_log 定义管理员操作日志(操作审计)查询接口契约。
|
||||||
|
//
|
||||||
|
// 路由前缀 /api/service/admin/system/log,对应 admin_menu:
|
||||||
|
// - POST /api/service/admin/system/log —— system:log:list(操作日志分页查询)
|
||||||
|
package admin_system_log
|
||||||
|
|
||||||
|
import "github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
|
// LogItem 一条管理员操作日志记录。
|
||||||
|
type LogItem struct {
|
||||||
|
Id uint64 `json:"id"`
|
||||||
|
AdminID uint64 `json:"adminId"` // 操作人ID
|
||||||
|
AdminUsername string `json:"adminUsername"` // 操作人账号(冗余)
|
||||||
|
Permission string `json:"permission"` // 命中的权限码
|
||||||
|
Method string `json:"method"` // HTTP 方法
|
||||||
|
Path string `json:"path"` // 请求路径
|
||||||
|
IP string `json:"ip"` // 来源 IP
|
||||||
|
IpLocation string `json:"ipLocation"` // IP 归属地
|
||||||
|
Param string `json:"param"` // 请求参数
|
||||||
|
DurationMS uint `json:"durationMs"` // 耗时(毫秒)
|
||||||
|
StatusCode int `json:"statusCode"` // HTTP 状态码
|
||||||
|
ErrorMessage string `json:"errorMessage"` // 失败原因
|
||||||
|
UserAgent string `json:"userAgent"` // 浏览器 UA
|
||||||
|
CreatedAt string `json:"createdAt"` // 操作时间
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogListReq 分页查询管理员操作日志。
|
||||||
|
type LogListReq struct {
|
||||||
|
g.Meta `path:"/system/log" method:"post" tags:"Admin/System/Log" summary:"操作日志列表"`
|
||||||
|
Page int `json:"page" d:"1" v:"min:1"`
|
||||||
|
Size int `json:"size" d:"10" v:"min:1|max:100"`
|
||||||
|
AdminID uint64 `json:"adminId"` // 按管理员ID精确过滤
|
||||||
|
Username string `json:"username"` // 按管理员账号模糊过滤
|
||||||
|
IP string `json:"ip"` // 按来源 IP 模糊过滤
|
||||||
|
IpLocation string `json:"ipLocation"` // 按 IP 归属地模糊过滤
|
||||||
|
Method string `json:"method"` // 按 HTTP 方法精确过滤
|
||||||
|
Status int `json:"status"` // 结果筛选:0 全部 / 1 成功 / 2 失败
|
||||||
|
Keyword string `json:"keyword"` // 关键字:匹配 permission / path / ip
|
||||||
|
StartTime string `json:"startTime"` // 起始时间,格式 2006-01-02 15:04:05
|
||||||
|
EndTime string `json:"endTime"` // 结束时间,格式 2006-01-02 15:04:05
|
||||||
|
MinDuration int `json:"minDuration"` // 耗时下限(ms),0 不限
|
||||||
|
MaxDuration int `json:"maxDuration"` // 耗时上限(ms),0 不限
|
||||||
|
OrderBy string `json:"orderBy"` // 排序字段:createdAt(默认) | durationMs
|
||||||
|
OrderDir string `json:"orderDir"` // 排序方向:desc(默认) | asc
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogListRes 是 LogListReq 的响应。
|
||||||
|
type LogListRes struct {
|
||||||
|
List []*LogItem `json:"list"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
129
internal/service/admin/base/log/log.go
Normal file
129
internal/service/admin/base/log/log.go
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
// Package admin_base_log 提供服务器日志文件管理服务:列出日志目录下的文件、读取文件尾部。
|
||||||
|
//
|
||||||
|
// 日志目录取自配置项 logger.path(默认 "log",相对项目根目录)。
|
||||||
|
// 安全约束:只允许访问日志目录内的文件,文件名做路径穿越校验,禁止读取目录外内容。
|
||||||
|
package admin_base_log
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gogf/gf/v2/errors/gerror"
|
||||||
|
"github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
|
"service.xpcool.com/internal/model/dto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ILogManage 服务器日志管理服务接口。
|
||||||
|
type ILogManage interface {
|
||||||
|
// Files 列出日志目录下的全部日志文件。
|
||||||
|
Files(context.Context) (dir string, files []*dto.LogFile, err error)
|
||||||
|
// Tail 读取指定日志文件的尾部若干行,可按关键字过滤。
|
||||||
|
Tail(ctx context.Context, file string, lines int, keyword string) ([]string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
var localLogManage ILogManage
|
||||||
|
|
||||||
|
// LogManage 获取服务器日志管理服务实例。
|
||||||
|
func LogManage() ILogManage {
|
||||||
|
if localLogManage == nil {
|
||||||
|
panic("LogManage 实现未注册")
|
||||||
|
}
|
||||||
|
return localLogManage
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterLogManage 注册服务器日志管理服务实现。
|
||||||
|
func RegisterLogManage(i ILogManage) { localLogManage = i }
|
||||||
|
|
||||||
|
// NewLogManage 创建服务器日志管理服务实例。
|
||||||
|
func NewLogManage() ILogManage { return &logManage{} }
|
||||||
|
|
||||||
|
type logManage struct{}
|
||||||
|
|
||||||
|
// logDir 返回日志目录的绝对路径。
|
||||||
|
func (s *logManage) logDir(ctx context.Context) string {
|
||||||
|
dir := g.Cfg().MustGet(ctx, "logger.path", "log").String()
|
||||||
|
if dir == "" {
|
||||||
|
dir = "log"
|
||||||
|
}
|
||||||
|
if abs, err := filepath.Abs(dir); err == nil {
|
||||||
|
return abs
|
||||||
|
}
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
// Files 列出日志目录下的全部日志文件(不递归子目录)。
|
||||||
|
func (s *logManage) Files(ctx context.Context) (string, []*dto.LogFile, error) {
|
||||||
|
dir := s.logDir(ctx)
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
// 日志目录尚未创建属正常情况(服务还没写日志),返回空列表而非报错。
|
||||||
|
return dir, []*dto.LogFile{}, nil
|
||||||
|
}
|
||||||
|
return "", nil, gerror.Wrap(err, "读取日志目录失败")
|
||||||
|
}
|
||||||
|
files := make([]*dto.LogFile, 0, len(entries))
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info, err := e.Info()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
files = append(files, &dto.LogFile{
|
||||||
|
Name: e.Name(),
|
||||||
|
Path: filepath.Join(dir, e.Name()),
|
||||||
|
Size: info.Size(),
|
||||||
|
ModTime: info.ModTime().Format("2006-01-02 15:04:05"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return dir, files, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tail 读取指定日志文件的尾部 lines 行;keyword 非空时只返回包含该关键字的行。
|
||||||
|
func (s *logManage) Tail(ctx context.Context, file string, lines int, keyword string) ([]string, error) {
|
||||||
|
if lines <= 0 {
|
||||||
|
lines = 200
|
||||||
|
}
|
||||||
|
dir := s.logDir(ctx)
|
||||||
|
// 路径穿越防护:文件名不允许包含路径分隔符与上跳符。
|
||||||
|
if file == "" || strings.ContainsAny(file, `/\`) || strings.Contains(file, "..") {
|
||||||
|
return nil, gerror.New("非法的日志文件名")
|
||||||
|
}
|
||||||
|
full := filepath.Join(dir, file)
|
||||||
|
|
||||||
|
data, err := os.ReadFile(full)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, gerror.New("日志文件不存在")
|
||||||
|
}
|
||||||
|
return nil, gerror.Wrap(err, "读取日志文件失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
all := strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n")
|
||||||
|
// 末尾通常是空行,去掉以免占用一行额度。
|
||||||
|
if n := len(all); n > 0 && all[n-1] == "" {
|
||||||
|
all = all[:n-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关键字过滤。
|
||||||
|
if keyword != "" {
|
||||||
|
filtered := make([]string, 0, len(all))
|
||||||
|
for _, l := range all {
|
||||||
|
if strings.Contains(l, keyword) {
|
||||||
|
filtered = append(filtered, l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
all = filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只取尾部 lines 行。
|
||||||
|
if len(all) > lines {
|
||||||
|
all = all[len(all)-lines:]
|
||||||
|
}
|
||||||
|
return all, nil
|
||||||
|
}
|
||||||
192
internal/service/admin/system/log/log.go
Normal file
192
internal/service/admin/system/log/log.go
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
// Package admin_system_log 提供管理员操作日志(操作审计)服务:写(Record,中间件调用落库)与读(List,分页查询)。
|
||||||
|
package admin_system_log
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gogf/gf/v2/errors/gerror"
|
||||||
|
|
||||||
|
"service.xpcool.com/internal/dao"
|
||||||
|
"service.xpcool.com/internal/model/do"
|
||||||
|
"service.xpcool.com/internal/model/dto"
|
||||||
|
"service.xpcool.com/internal/model/entity"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuditEvent 一次管理员操作的审计事件,由 AdminAuth 中间件在请求结束后回调传入。
|
||||||
|
type AuditEvent struct {
|
||||||
|
AdminID uint64
|
||||||
|
Permission string
|
||||||
|
Method string
|
||||||
|
Path string
|
||||||
|
IP string
|
||||||
|
IPLocation string
|
||||||
|
Param string
|
||||||
|
DurationMS int
|
||||||
|
StatusCode int
|
||||||
|
ErrorMessage string
|
||||||
|
UserAgent string
|
||||||
|
}
|
||||||
|
|
||||||
|
// IAdminAudit 操作审计服务接口:审计写入 + 分页查询。
|
||||||
|
type IAdminAudit interface {
|
||||||
|
Record(context.Context, AuditEvent) error
|
||||||
|
List(context.Context, dto.LogQuery) ([]*dto.LogItem, int, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
var localAdminAudit IAdminAudit
|
||||||
|
|
||||||
|
// AdminAudit 获取操作审计服务实例。
|
||||||
|
func AdminAudit() IAdminAudit {
|
||||||
|
if localAdminAudit == nil {
|
||||||
|
panic("AdminAudit 实现未注册")
|
||||||
|
}
|
||||||
|
return localAdminAudit
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterAdminAudit 注册操作审计服务实现。
|
||||||
|
func RegisterAdminAudit(i IAdminAudit) { localAdminAudit = i }
|
||||||
|
|
||||||
|
// NewAdminAudit 创建操作审计服务实例。
|
||||||
|
func NewAdminAudit() IAdminAudit { return &adminAudit{} }
|
||||||
|
|
||||||
|
type adminAudit struct{}
|
||||||
|
|
||||||
|
// Record 写入一条操作审计记录。
|
||||||
|
// 说明:审计失败不应影响业务请求,调用方(中间件)已忽略此处返回的 error,仅打日志。
|
||||||
|
func (s *adminAudit) Record(ctx context.Context, e AuditEvent) error {
|
||||||
|
// 参数与 UA 可能很长,按表结构上限截断(request_param/user_agent VARCHAR(512),error_message VARCHAR(512))。
|
||||||
|
param := truncate(e.Param, 512)
|
||||||
|
ua := truncate(e.UserAgent, 512)
|
||||||
|
errMsg := truncate(e.ErrorMessage, 512)
|
||||||
|
ipLoc := truncate(e.IPLocation, 255)
|
||||||
|
|
||||||
|
_, err := dao.AdminOperationLog.Ctx(ctx).Data(do.AdminOperationLog{
|
||||||
|
AdminUserId: e.AdminID,
|
||||||
|
Permission: e.Permission,
|
||||||
|
Method: e.Method,
|
||||||
|
Path: e.Path,
|
||||||
|
Ip: e.IP,
|
||||||
|
IpLocation: ipLoc,
|
||||||
|
RequestParam: param,
|
||||||
|
DurationMs: uint(e.DurationMS),
|
||||||
|
StatusCode: e.StatusCode,
|
||||||
|
ErrorMessage: errMsg,
|
||||||
|
UserAgent: ua,
|
||||||
|
}).Insert()
|
||||||
|
return gerror.Wrap(err, "写入操作日志失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
// List 分页查询操作日志,支持账号/IP/方法/结果/耗时/关键字/时间范围过滤与排序。
|
||||||
|
func (s *adminAudit) List(ctx context.Context, q dto.LogQuery) ([]*dto.LogItem, int, error) {
|
||||||
|
m := dao.AdminOperationLog.Ctx(ctx)
|
||||||
|
|
||||||
|
// 过滤条件。
|
||||||
|
if q.AdminID > 0 {
|
||||||
|
m = m.Where(dao.AdminOperationLog.Columns().AdminUserId, q.AdminID)
|
||||||
|
}
|
||||||
|
if q.Username != "" {
|
||||||
|
m = m.WhereLike("admin_username", "%"+q.Username+"%")
|
||||||
|
}
|
||||||
|
if q.IP != "" {
|
||||||
|
m = m.WhereLike(dao.AdminOperationLog.Columns().Ip, "%"+q.IP+"%")
|
||||||
|
}
|
||||||
|
if q.IpLocation != "" {
|
||||||
|
m = m.WhereLike("ip_location", "%"+q.IpLocation+"%")
|
||||||
|
}
|
||||||
|
if q.Method != "" {
|
||||||
|
m = m.Where(dao.AdminOperationLog.Columns().Method, strings.ToUpper(q.Method))
|
||||||
|
}
|
||||||
|
// 结果筛选:1 成功(2xx) / 2 失败(非 2xx)。
|
||||||
|
switch q.Status {
|
||||||
|
case 1:
|
||||||
|
m = m.Where("status_code >= ? AND status_code < ?", 200, 300)
|
||||||
|
case 2:
|
||||||
|
m = m.Where("status_code < ? OR status_code >= ?", 200, 300)
|
||||||
|
}
|
||||||
|
if q.Keyword != "" {
|
||||||
|
like := "%" + q.Keyword + "%"
|
||||||
|
m = m.Where(
|
||||||
|
"(permission LIKE ? OR path LIKE ? OR ip LIKE ?)",
|
||||||
|
like, like, like,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if q.StartTime != "" {
|
||||||
|
m = m.WhereGTE(dao.AdminOperationLog.Columns().CreatedAt, q.StartTime)
|
||||||
|
}
|
||||||
|
if q.EndTime != "" {
|
||||||
|
m = m.WhereLTE(dao.AdminOperationLog.Columns().CreatedAt, q.EndTime)
|
||||||
|
}
|
||||||
|
if q.MinDuration > 0 {
|
||||||
|
m = m.WhereGTE(dao.AdminOperationLog.Columns().DurationMs, q.MinDuration)
|
||||||
|
}
|
||||||
|
if q.MaxDuration > 0 {
|
||||||
|
m = m.WhereLTE(dao.AdminOperationLog.Columns().DurationMs, q.MaxDuration)
|
||||||
|
}
|
||||||
|
|
||||||
|
total, err := m.Count()
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, gerror.Wrap(err, "统计操作日志总数失败")
|
||||||
|
}
|
||||||
|
if total == 0 {
|
||||||
|
return nil, 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 排序:白名单字段,避免 SQL 注入。
|
||||||
|
switch q.OrderBy {
|
||||||
|
case "durationMs":
|
||||||
|
if strings.EqualFold(q.OrderDir, "asc") {
|
||||||
|
m = m.OrderAsc(dao.AdminOperationLog.Columns().DurationMs)
|
||||||
|
} else {
|
||||||
|
m = m.OrderDesc(dao.AdminOperationLog.Columns().DurationMs)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if strings.EqualFold(q.OrderDir, "asc") {
|
||||||
|
m = m.OrderAsc(dao.AdminOperationLog.Columns().CreatedAt)
|
||||||
|
} else {
|
||||||
|
m = m.OrderDesc(dao.AdminOperationLog.Columns().CreatedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var list []entity.AdminOperationLog
|
||||||
|
if err = m.Page(q.Page, q.Size).Scan(&list); err != nil {
|
||||||
|
return nil, 0, gerror.Wrap(err, "查询操作日志列表失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]*dto.LogItem, 0, len(list))
|
||||||
|
for i := range list {
|
||||||
|
l := &list[i]
|
||||||
|
createdAt := ""
|
||||||
|
if l.CreatedAt != nil {
|
||||||
|
createdAt = l.CreatedAt.Layout("2006-01-02 15:04:05")
|
||||||
|
}
|
||||||
|
items = append(items, &dto.LogItem{
|
||||||
|
Id: l.Id,
|
||||||
|
AdminID: l.AdminUserId,
|
||||||
|
AdminUsername: l.AdminUsername,
|
||||||
|
Permission: l.Permission,
|
||||||
|
Method: l.Method,
|
||||||
|
Path: l.Path,
|
||||||
|
IP: l.Ip,
|
||||||
|
IpLocation: l.IpLocation,
|
||||||
|
Param: l.RequestParam,
|
||||||
|
DurationMS: l.DurationMs,
|
||||||
|
StatusCode: l.StatusCode,
|
||||||
|
ErrorMessage: l.ErrorMessage,
|
||||||
|
UserAgent: l.UserAgent,
|
||||||
|
CreatedAt: createdAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return items, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncate 按最大字符数截断字符串,超长时保留前 n 个字符并追加省略标记。
|
||||||
|
func truncate(s string, n int) string {
|
||||||
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
if n <= 3 {
|
||||||
|
return s[:n]
|
||||||
|
}
|
||||||
|
return s[:n-3] + "..."
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user