Files
olons_admin/server/internal/logic/sys/url_pool.go
T

449 lines
16 KiB
Go

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[:])
}