package sys import ( "context" "crypto/sha256" "encoding/hex" "fmt" "io" "net" "net/url" "strings" "unicode/utf8" "hotgo/internal/consts" "hotgo/internal/dao" "hotgo/internal/library/contexts" "hotgo/internal/library/hgorm/handler" "hotgo/internal/library/hgorm/hook" "hotgo/internal/model/entity" "hotgo/internal/model/input/sysin" "hotgo/internal/service" "hotgo/utility/encrypt" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" "github.com/xuri/excelize/v2" ) type sSysUrlPool struct{} func NewSysUrlPool() *sSysUrlPool { return &sSysUrlPool{} } func init() { service.RegisterSysUrlPool(NewSysUrlPool()) } func (s *sSysUrlPool) Model(ctx context.Context) *gdb.Model { return handler.Model(dao.UrlPool.Ctx(ctx), &handler.Option{FilterTenant: true}) } func (s *sSysUrlPool) bindingModel(ctx context.Context) *gdb.Model { return handler.Model(dao.MemberUrlBinding.Ctx(ctx), &handler.Option{FilterTenant: true}) } func (s *sSysUrlPool) List(ctx context.Context, in *sysin.UrlPoolListInp) (list []*sysin.UrlPoolListModel, totalCount int, err error) { pc, bc := dao.UrlPool.Columns(), dao.MemberUrlBinding.Columns() mod := s.Model(ctx).As("p") if in.Name != "" { mod = mod.WhereLike("p."+pc.Name, "%"+in.Name+"%") } if in.Domain != "" { mod = mod.WhereLike("p."+pc.Domain, "%"+strings.ToLower(strings.TrimSpace(in.Domain))+"%") } if in.Status > 0 { mod = mod.Where("p."+pc.Status, in.Status) } if len(in.CreatedAt) == 2 { mod = mod.WhereBetween("p."+pc.CreatedAt, in.CreatedAt[0], in.CreatedAt[1]) } totalCount, err = mod.Clone().Count() if err != nil || totalCount == 0 { return } mod = mod.Fields( "p."+pc.Id, "p."+pc.Name, "p."+pc.Scheme, "p."+pc.Domain, "p."+pc.BaseUrl, "p."+pc.Status, "p."+pc.BindCount, "p."+pc.LastBoundAt, "p."+pc.Remark, "p."+pc.CreatedAt, "p."+pc.UpdatedAt, fmt.Sprintf("(SELECT COUNT(*) FROM %s b WHERE b.%s=p.%s) current_bound_count", dao.MemberUrlBinding.Table(), bc.UrlPoolId, pc.Id), ) err = mod.Page(in.Page, in.PerPage).OrderAsc("p." + pc.BindCount).OrderAsc("p." + pc.Id).Scan(&list) return } func (s *sSysUrlPool) View(ctx context.Context, in *sysin.UrlPoolViewInp) (res *sysin.UrlPoolViewModel, err error) { pc, bc := dao.UrlPool.Columns(), dao.MemberUrlBinding.Columns() res = new(sysin.UrlPoolViewModel) err = s.Model(ctx).As("p").Fields( "p.*", fmt.Sprintf("(SELECT COUNT(*) FROM %s b WHERE b.%s=p.%s) current_bound_count", dao.MemberUrlBinding.Table(), bc.UrlPoolId, pc.Id), ).Where("p."+pc.Id, in.Id).Scan(res) if err != nil { return nil, gerror.Wrap(err, "查询URL地址详情失败") } if res.Id == 0 { return nil, gerror.New("URL地址不存在") } return } func (s *sSysUrlPool) Edit(ctx context.Context, in *sysin.UrlPoolEditInp) (err error) { scheme, domain, baseURL, err := normalizeRegisterURL(in.BaseUrl) if err != nil { return err } hash := domainHash(domain) pc := dao.UrlPool.Columns() var before *entity.UrlPool if in.Id > 0 { if err = s.Model(ctx).WherePri(in.Id).Scan(&before); err != nil { return gerror.Wrap(err, "读取URL地址失败") } if before == nil { return gerror.New("URL地址不存在") } if before.Domain != domain { count, e := s.bindingModel(ctx).Where(dao.MemberUrlBinding.Columns().UrlPoolId, in.Id).Count() if e != nil { return gerror.Wrap(e, "检查URL地址绑定失败") } if count > 0 { return gerror.New("该地址已绑定会员,不能修改域名") } } } count, err := s.Model(ctx).Where(pc.DomainHash, hash).WhereNot(pc.Id, in.Id).Count() if err != nil { return gerror.Wrap(err, "检查URL地址重复失败") } if count > 0 { return gerror.New("该注册域名已存在") } data := g.Map{ pc.Name: in.Name, pc.Scheme: scheme, pc.Domain: domain, pc.DomainHash: hash, pc.BaseUrl: baseURL, pc.Status: in.Status, pc.Remark: in.Remark, pc.UpdatedBy: contexts.GetUserId(ctx), } if in.Id > 0 { _, err = s.Model(ctx).Fields(pc.Name, pc.Scheme, pc.Domain, pc.DomainHash, pc.BaseUrl, pc.Status, pc.Remark, pc.UpdatedBy).WherePri(in.Id).Data(data).Hook(hook.SaveTenant).Update() } else { data[pc.CreatedBy] = contexts.GetUserId(ctx) result, e := dao.UrlPool.Ctx(ctx).Fields(pc.Name, pc.Scheme, pc.Domain, pc.DomainHash, pc.BaseUrl, pc.Status, pc.Remark, pc.CreatedBy, pc.UpdatedBy).Hook(hook.SaveTenant).Data(data).Insert() if e != nil { err = e } else { id, _ := result.LastInsertId() in.Id = uint64(id) } } if err != nil { return gerror.Wrap(err, "保存URL地址失败") } return service.SysBizAudit().Record(ctx, &sysin.BizAuditRecordInp{Module: "url_pool", Action: "url_pool.edit", TargetType: "url_pool", TargetId: in.Id, Before: before, After: data}) } func (s *sSysUrlPool) Status(ctx context.Context, in *sysin.UrlPoolStatusInp) error { pc := dao.UrlPool.Columns() var before *entity.UrlPool if err := s.Model(ctx).WherePri(in.Id).Scan(&before); err != nil { return gerror.Wrap(err, "读取URL地址失败") } if before == nil { return gerror.New("URL地址不存在") } if before.Status == in.Status { return nil } if _, err := s.Model(ctx).Fields(pc.Status, pc.UpdatedBy).WherePri(in.Id).Data(g.Map{pc.Status: in.Status, pc.UpdatedBy: contexts.GetUserId(ctx)}).Hook(hook.SaveTenant).Update(); err != nil { return gerror.Wrap(err, "更新URL地址状态失败") } return service.SysBizAudit().Record(ctx, &sysin.BizAuditRecordInp{Module: "url_pool", Action: "url_pool.status", TargetType: "url_pool", TargetId: in.Id, Before: g.Map{"status": before.Status}, After: g.Map{"status": in.Status}}) } func (s *sSysUrlPool) Import(ctx context.Context, reader io.Reader) (res *sysin.UrlPoolImportModel, err error) { book, err := excelize.OpenReader(reader) if err != nil { return nil, gerror.New("Excel文件无法解析,请确认文件格式正确") } defer book.Close() sheets := book.GetSheetList() if len(sheets) == 0 { return nil, gerror.New("Excel文件中没有工作表") } rows, err := book.GetRows(sheets[0]) if err != nil { return nil, gerror.Wrap(err, "读取Excel内容失败") } if len(rows) < 2 { return nil, gerror.New("Excel中没有可导入的数据") } nameColumn, urlColumn, remarkColumn := findUrlPoolImportColumns(rows[0]) if nameColumn < 0 || urlColumn < 0 { return nil, gerror.New("Excel表头必须包含“地址名称”和“注册入口URL”") } type importCandidate struct { domainHash string data g.Map } res = &sysin.UrlPoolImportModel{Issues: make([]*sysin.UrlPoolImportIssue, 0)} candidates := make([]importCandidate, 0, len(rows)-1) fileDomains := make(map[string]struct{}) pc := dao.UrlPool.Columns() for index := 1; index < len(rows); index++ { row := rows[index] name := importCell(row, nameColumn) rawURL := importCell(row, urlColumn) remark := importCell(row, remarkColumn) if name == "" && rawURL == "" && remark == "" { continue } res.TotalRows++ rowNumber := index + 1 if name == "" { appendUrlPoolImportIssue(res, rowNumber, "地址名称不能为空") continue } if utf8.RuneCountInString(name) > 100 { appendUrlPoolImportIssue(res, rowNumber, "地址名称不能超过100个字符") continue } if utf8.RuneCountInString(remark) > 500 { appendUrlPoolImportIssue(res, rowNumber, "备注不能超过500个字符") continue } scheme, domain, baseURL, normalizeErr := normalizeRegisterURL(rawURL) if normalizeErr != nil { appendUrlPoolImportIssue(res, rowNumber, normalizeErr.Error()) continue } if len(baseURL) > 512 { appendUrlPoolImportIssue(res, rowNumber, "注册入口URL不能超过512个字符") continue } hash := domainHash(domain) if _, exists := fileDomains[hash]; exists { res.DuplicateCount++ continue } fileDomains[hash] = struct{}{} candidates = append(candidates, importCandidate{domainHash: hash, data: g.Map{ pc.Name: name, pc.Scheme: scheme, pc.Domain: domain, pc.DomainHash: hash, pc.BaseUrl: baseURL, pc.Status: consts.UrlPoolStatusEnabled, pc.Remark: remark, pc.CreatedBy: contexts.GetUserId(ctx), pc.UpdatedBy: contexts.GetUserId(ctx), }}) } if res.TotalRows > 5000 { return nil, gerror.New("单次最多导入5000条地址") } if len(candidates) == 0 { return res, nil } hashes := make([]string, 0, len(candidates)) for _, candidate := range candidates { hashes = append(hashes, candidate.domainHash) } existingValues, err := s.Model(ctx).Fields(pc.DomainHash).WhereIn(pc.DomainHash, hashes).Array() if err != nil { return nil, gerror.Wrap(err, "检查重复注册域名失败") } existing := make(map[string]struct{}, len(existingValues)) for _, value := range existingValues { existing[value.String()] = struct{}{} } insertRows := make(g.List, 0, len(candidates)) for _, candidate := range candidates { if _, exists := existing[candidate.domainHash]; exists { res.DuplicateCount++ continue } insertRows = append(insertRows, candidate.data) } if len(insertRows) == 0 { return res, nil } err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { _, insertErr := dao.UrlPool.Ctx(ctx).Fields( pc.Name, pc.Scheme, pc.Domain, pc.DomainHash, pc.BaseUrl, pc.Status, pc.Remark, pc.CreatedBy, pc.UpdatedBy, ).Hook(hook.SaveTenant).Data(insertRows).Insert() return insertErr }) if err != nil { return nil, gerror.Wrap(err, "批量导入URL地址失败") } res.SuccessCount = len(insertRows) if auditErr := service.SysBizAudit().Record(ctx, &sysin.BizAuditRecordInp{ Module: "url_pool", Action: "url_pool.import", TargetType: "url_pool", After: g.Map{ "totalRows": res.TotalRows, "successCount": res.SuccessCount, "duplicateCount": res.DuplicateCount, "failedCount": res.FailedCount, }, }); auditErr != nil { return nil, auditErr } return res, nil } func findUrlPoolImportColumns(header []string) (nameColumn, urlColumn, remarkColumn int) { nameColumn, urlColumn, remarkColumn = -1, -1, -1 for index, value := range header { value = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(value, "\ufeff"))) switch value { case "地址名称", "名称", "name": nameColumn = index case "注册入口url", "注册入口", "入口url", "baseurl", "base_url": urlColumn = index case "备注", "remark": remarkColumn = index } } return } func importCell(row []string, column int) string { if column < 0 || column >= len(row) { return "" } return strings.TrimSpace(row[column]) } func appendUrlPoolImportIssue(res *sysin.UrlPoolImportModel, row int, message string) { res.FailedCount++ if len(res.Issues) < 100 { res.Issues = append(res.Issues, &sysin.UrlPoolImportIssue{Row: row, Message: message}) } } func (s *sSysUrlPool) MemberList(ctx context.Context, in *sysin.UrlPoolMemberListInp) (list []*sysin.UrlPoolMemberListModel, totalCount int, err error) { bc, mc := dao.MemberUrlBinding.Columns(), dao.AppMember.Columns() mod := s.bindingModel(ctx).As("b").LeftJoin(dao.AppMember.Table()+" m", "m."+mc.Id+"=b."+bc.MemberId).Where("b."+bc.UrlPoolId, in.UrlPoolId) if in.Keyword != "" { like := "%" + strings.TrimSpace(in.Keyword) + "%" mod = mod.Where(fmt.Sprintf("(m.%s LIKE ? OR m.%s LIKE ? OR m.%s LIKE ?)", mc.MemberNo, mc.Username, mc.Mobile), like, like, like) } if len(in.BoundAt) == 2 { mod = mod.WhereBetween("b."+bc.BoundAt, in.BoundAt[0], in.BoundAt[1]) } totalCount, err = mod.Clone().Count() if err != nil || totalCount == 0 { return } mod = mod.Fields( "b."+bc.MemberId, "m."+mc.MemberNo, "m."+mc.Username, "m."+mc.Mobile, "m."+mc.MemberStatus, "b."+bc.BoundAt, ) err = mod.Page(in.Page, in.PerPage).OrderDesc("b." + bc.Id).Scan(&list) for _, row := range list { row.Mobile = encrypt.MaskMobile(row.Mobile) } return } func (s *sSysUrlPool) Summary(ctx context.Context, in *sysin.UrlPoolSummaryInp) (res *sysin.UrlPoolSummaryModel, err error) { res = new(sysin.UrlPoolSummaryModel) pc, bc, mc := dao.UrlPool.Columns(), dao.MemberUrlBinding.Columns(), dao.AppMember.Columns() if res.TotalCount, err = s.Model(ctx).Count(); err != nil { return nil, err } res.EnabledCount, err = s.Model(ctx).Where(pc.Status, consts.UrlPoolStatusEnabled).Count() if err != nil { return nil, err } res.InvalidCount, err = s.Model(ctx).Where(pc.Status, consts.UrlPoolStatusInvalid).Count() if err != nil { return nil, err } res.BoundMemberCount, err = s.bindingModel(ctx).Count() if err != nil { return nil, err } res.UnboundMemberCount, err = handler.Model(dao.AppMember.Ctx(ctx), &handler.Option{FilterTenant: true}).As("m").LeftJoin(dao.MemberUrlBinding.Table()+" b", "b."+bc.MemberId+"=m."+mc.Id).WhereNull("b." + bc.Id).Count() if err != nil { return nil, err } res.InvalidMemberCount, err = s.bindingModel(ctx).As("b").InnerJoin(dao.UrlPool.Table()+" p", "p."+pc.Id+"=b."+bc.UrlPoolId).Where("p."+pc.Status, consts.UrlPoolStatusInvalid).Count() return } func (s *sSysUrlPool) BindOnRegister(ctx context.Context, memberId uint64, origin, host string) (bound bool, domain string, err error) { domain, normalizedOrigin, err := resolveRequestDomain(origin, host) if err != nil || domain == "" { return false, "", nil } pc, bc := dao.UrlPool.Columns(), dao.MemberUrlBinding.Columns() var pool *entity.UrlPool err = s.Model(ctx).Where(pc.DomainHash, domainHash(domain)).Where(pc.Status, consts.UrlPoolStatusEnabled).Scan(&pool) if err != nil { return false, domain, gerror.Wrap(err, "查询注册域名失败") } if pool == nil { return false, domain, nil } var existing *entity.MemberUrlBinding if err = s.bindingModel(ctx).Where(bc.MemberId, memberId).Scan(&existing); err != nil { return false, domain, gerror.Wrap(err, "查询会员域名绑定失败") } if existing != nil { return existing.UrlPoolId == pool.Id, existing.DomainSnapshot, nil } now := gtime.Now() if _, err = dao.MemberUrlBinding.Ctx(ctx).Fields(bc.TenantId, bc.MemberId, bc.UrlPoolId, bc.DomainSnapshot, bc.OriginSnapshot, bc.BoundAt).Data(g.Map{bc.TenantId: pool.TenantId, bc.MemberId: memberId, bc.UrlPoolId: pool.Id, bc.DomainSnapshot: domain, bc.OriginSnapshot: normalizedOrigin, bc.BoundAt: now}).Insert(); err != nil { return false, domain, gerror.Wrap(err, "创建会员域名绑定失败") } if _, err = s.Model(ctx).Fields(pc.BindCount, pc.LastBoundAt).WherePri(pool.Id).Data(g.Map{pc.BindCount: gdb.Raw(pc.BindCount + "+1"), pc.LastBoundAt: now}).Update(); err != nil { return false, domain, gerror.Wrap(err, "更新域名绑定次数失败") } return true, domain, nil } func normalizeRegisterURL(raw string) (scheme, domain, baseURL string, err error) { raw = strings.TrimSpace(raw) u, err := url.Parse(raw) if err != nil || u.Scheme == "" || u.Host == "" { return "", "", "", gerror.New("注册入口URL格式不正确") } if u.Scheme != "http" && u.Scheme != "https" { return "", "", "", gerror.New("注册入口只支持HTTP或HTTPS") } if u.User != nil || u.RawQuery != "" || u.Fragment != "" { return "", "", "", gerror.New("注册入口不能包含账号、查询参数或片段") } hostname := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(u.Hostname())), ".") if hostname == "" { return "", "", "", gerror.New("注册域名不能为空") } port := u.Port() if (u.Scheme == "https" && port == "443") || (u.Scheme == "http" && port == "80") { port = "" } domain = hostname if port != "" { domain = net.JoinHostPort(hostname, port) } path := strings.TrimSpace(u.EscapedPath()) if path == "" { path = "/register" } return strings.ToLower(u.Scheme), domain, strings.ToLower(u.Scheme) + "://" + domain + path, nil } func resolveRequestDomain(origin, host string) (domain, normalizedOrigin string, err error) { if strings.TrimSpace(origin) != "" { u, e := url.Parse(strings.TrimSpace(origin)) if e == nil && u.Scheme != "" && u.Host != "" && u.User == nil { _, domain, normalizedOrigin, e = normalizeRegisterURL(u.Scheme + "://" + u.Host + "/register") return domain, strings.TrimSuffix(normalizedOrigin, "/register"), e } } host = strings.TrimSpace(host) if host == "" { return "", "", nil } _, domain, normalizedOrigin, err = normalizeRegisterURL("https://" + host + "/register") return domain, strings.TrimSuffix(normalizedOrigin, "/register"), err } func domainHash(domain string) string { sum := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(domain)))) return hex.EncodeToString(sum[:]) }