449 lines
16 KiB
Go
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[:])
|
|
}
|