diff --git a/pkg/cache/cache.go b/pkg/cache/cache.go index 42b3d34..f622c9c 100644 --- a/pkg/cache/cache.go +++ b/pkg/cache/cache.go @@ -7,6 +7,7 @@ import ( "fmt" "sort" "strings" + "sync" "time" "github.com/eiblog/eiblog/pkg/cache/render" @@ -37,6 +38,7 @@ func init() { } // Ei init Ei = &Cache{ + lock: sync.Mutex{}, Store: store, TagArticles: make(map[string]model.SortedArticles), ArticlesMap: make(map[string]*model.Article), @@ -52,6 +54,7 @@ func init() { // Cache 整站缓存 type Cache struct { + lock sync.Mutex store.Store // load from db @@ -68,44 +71,6 @@ type Cache struct { ArticlesMap map[string]*model.Article // slug:article } -// // LoadInsertAccount 读取或创建账户 -// LoadInsertAccount(ctx context.Context, acct *model.Account) (*model.Account, error) -// // UpdateAccount 更新账户 -// UpdateAccount(ctx context.Context, name string, fields map[string]interface{}) error -// -// // LoadInsertBlogger 读取或创建博客 -// LoadInsertBlogger(ctx context.Context, blogger *model.Blogger) (*model.Blogger, error) -// // UpdateBlogger 更新博客 -// UpdateBlogger(ctx context.Context, fields map[string]interface{}) error -// -// // InsertSeries 创建专题 -// InsertSeries(ctx context.Context, series *model.Series) error -// // RemoveSeries 删除专题 -// RemoveSeries(ctx context.Context, id int) error -// // UpdateSeries 更新专题 -// UpdateSeries(ctx context.Context, id int, fields map[string]interface{}) error -// // LoadAllSeries 读取所有专题 -// LoadAllSeries(ctx context.Context) (model.SortedSeries, error) -// -// // InsertArticle 创建文章 -// InsertArticle(ctx context.Context, article *model.Article) error -// // RemoveArticle 硬删除文章 -// RemoveArticle(ctx context.Context, id int) error -// // DeleteArticle 软删除文章,放入回收箱 -// DeleteArticle(ctx context.Context, id int) error -// // CleanArticles 清理回收站文章 -// CleanArticles(ctx context.Context) error -// // UpdateArticle 更新文章 -// UpdateArticle(ctx context.Context, id int, fields map[string]interface{}) error -// // RecoverArticle 恢复文章到草稿 -// RecoverArticle(ctx context.Context, id int) error -// // LoadAllArticle 读取所有文章 -// LoadAllArticle(ctx context.Context) (model.SortedArticles, error) -// // LoadTrashArticles 读取回收箱 -// LoadTrashArticles(ctx context.Context) (model.SortedArticles, error) -// // LoadDraftArticles 读取草稿箱 -// LoadDraftArticles(ctx context.Context) (model.SortedArticles, error) - // PageArticles 文章翻页 func (c *Cache) PageArticles(page int, pageSize int) (prev, next int, articles []*model.Article) { @@ -146,6 +111,121 @@ func (c *Cache) PageArticles(page int, pageSize int) (prev, // // } +// DeleteArticles 删除文章 +func (c *Cache) DeleteArticles(ids []int) error { + c.lock.Lock() + defer c.lock.Unlock() + + for _, id := range ids { + article, idx := c.FindArticleByID(id) + // drop from linkedList + if article.Prev == nil && article.Next != nil { + article.Next.Prev = nil + } else if article.Prev != nil && article.Next == nil { + article.Prev.Next = nil + } else if article.Prev != nil && article.Next != nil { + article.Prev.Next = article.Next + article.Next.Prev = article.Prev + } + // drop from articles + c.Articles = append(c.Articles[:idx], c.Articles[idx+1:]...) + delete(c.ArticlesMap, article.Slug) + // set delete + err := c.UpdateArticle(context.Background(), id, map[string]interface{}{ + "deletetime": time.Now(), + }) + if err != nil { + return err + } + // drop from tags,series,archives + c.redelArticle(article) + } + return nil +} + +// AddArticle 添加文章 +func (c *Cache) AddArticle(article *model.Article) error { + err := c.InsertArticle(context.Background(), article) + if err != nil { + return err + } + // 正式发布文章 + if !article.IsDraft { + defer render.GenerateExcerptMarkdown(article) + + c.ArticlesMap[article.Slug] = article + c.Articles = append([]*model.Article{article}, c.Articles...) + sort.Sort(c.Articles) + + article, idx := c.FindArticleByID(article.ID) + if idx == 0 && c.Articles[idx+1].ID >= + config.Conf.BlogApp.General.StartID { + article.Next = c.Articles[idx+1] + c.Articles[idx+1].Prev = article + } else if idx > 0 && c.Articles[idx-1].ID >= + config.Conf.BlogApp.General.StartID { + article.Prev = c.Articles[idx-1] + if c.Articles[idx-1].Next != nil { + article.Next = c.Articles[idx-1].Next + c.Articles[idx-1].Next.Prev = article + } + c.Articles[idx-1].Next = article + } + c.readdArticle(article, true) + } + return nil +} + +// ReplaceArticle 替换文章 +func (c *Cache) ReplaceArticle(oldArticle, newArticle *model.Article) { + c.ArticlesMap[newArticle.Slug] = newArticle + render.GenerateExcerptMarkdown(newArticle) + if newArticle.ID < config.Conf.BlogApp.General.StartID { + return + } + if oldArticle != nil { + article, idx := c.FindArticleByID(oldArticle.ID) + if article.Prev == nil && article.Next != nil { + article.Next.Prev = nil + } else if article.Prev != nil && article.Next == nil { + article.Prev.Next = nil + } else if article.Prev != nil && article.Next != nil { + article.Prev.Next = article.Next + article.Next.Prev = article.Prev + } + c.Articles = append(Ei.Articles[:idx], Ei.Articles[idx+1:]...) + + c.redelArticle(article) + } + + c.Articles = append(c.Articles, newArticle) + sort.Sort(c.Articles) + _, idx := c.FindArticleByID(newArticle.ID) + if idx == 0 && c.Articles[idx+1].ID >= config.Conf.BlogApp.General.StartID { + newArticle.Next = c.Articles[idx+1] + c.Articles[idx+1].Prev = newArticle + } else if idx > 0 && c.Articles[idx-1].ID >= + config.Conf.BlogApp.General.StartID { + newArticle.Prev = Ei.Articles[idx-1] + if c.Articles[idx-1].Next != nil { + newArticle.Next = Ei.Articles[idx-1].Next + c.Articles[idx-1].Next.Prev = newArticle + } + c.Articles[idx-1].Next = newArticle + } + c.readdArticle(newArticle, true) +} + +// FindArticleByID 通过ID查找文章 +func (c *Cache) FindArticleByID(id int) (*model.Article, int) { + for i, article := range c.Articles { + if article.ID == id { + return article, i + } + } + return nil, -1 +} + // loadOrInit 读取数据或初始化 func (c *Cache) loadOrInit() error { blogapp := config.Conf.BlogApp @@ -219,7 +299,7 @@ func (c *Cache) loadOrInit() error { if Ei.Articles[i+1].ID >= blogapp.General.StartID { v.Next = Ei.Articles[i+1] } - c.rebuildArticle(v, false) + c.readdArticle(v, false) } Ei.Articles = articles // 重建专题与归档 @@ -228,8 +308,8 @@ func (c *Cache) loadOrInit() error { return nil } -// rebuildArticle 重建缓存tag、series、archive -func (c *Cache) rebuildArticle(article *model.Article, needSort bool) { +// readdArticle 添加文章到tag、series、archive +func (c *Cache) readdArticle(article *model.Article, needSort bool) { // tag tags := strings.Split(article.Tags, ",") for _, tag := range tags { @@ -270,6 +350,52 @@ func (c *Cache) rebuildArticle(article *model.Article, needSort bool) { } } +// redelArticle 从tag、series、archive删除文章 +func (c *Cache) redelArticle(article *model.Article) { + // tag + tags := strings.Split(article.Tags, ",") + for _, tag := range tags { + for i, v := range c.TagArticles[tag] { + if v == article { + c.TagArticles[tag] = append(c.TagArticles[tag][0:i], c.TagArticles[tag][i+1:]...) + if len(c.TagArticles[tag]) == 0 { + delete(c.TagArticles, tag) + } + } + } + } + // serie + for i, serie := range c.Series { + if serie.ID == article.SerieID { + for j, v := range serie.Articles { + if v == article { + Ei.Series[i].Articles = append(Ei.Series[i].Articles[0:j], + Ei.Series[i].Articles[j+1:]...) + PagesCh <- PageSeries + break + } + } + } + } + // archive + for i, archive := range c.Archives { + ay, am, _ := archive.Time.Date() + if y, m, _ := article.CreatedAt.Date(); ay == y && am == m { + for j, v := range archive.Articles { + if v == article { + c.Archives[i].Articles = append(c.Archives[i].Articles[0:j], + c.Archives[i].Articles[j+1:]...) + if len(c.Archives[i].Articles) == 0 { + c.Archives = append(c.Archives[:i], c.Archives[i+1:]...) + } + PagesCh <- PageArchive + break + } + } + } + } +} + // regeneratePages 重新生成series,archive页面 func (c *Cache) regeneratePages() { for { diff --git a/pkg/core/blog/admin/admin.go b/pkg/core/blog/admin/admin.go index 7f8fa2d..d0588c8 100644 --- a/pkg/core/blog/admin/admin.go +++ b/pkg/core/blog/admin/admin.go @@ -3,13 +3,17 @@ package admin import ( "context" + "errors" "fmt" "net/http" "strconv" "time" "github.com/eiblog/eiblog/pkg/cache" + "github.com/eiblog/eiblog/pkg/config" "github.com/eiblog/eiblog/pkg/core/blog" + "github.com/eiblog/eiblog/pkg/internal" + "github.com/eiblog/eiblog/pkg/model" "github.com/eiblog/eiblog/tools" "github.com/gin-gonic/gin" @@ -176,29 +180,229 @@ func handleAPIPassword(c *gin.Context) { // handleAPIPostDelete 删除文章,移入回收箱 func handleAPIPostDelete(c *gin.Context) { - // var ids []int32 - // for _, v := range c.PostFormArray("cid[]") { - // i, err := strconv.Atoi(v) - // if err != nil || int32(i) < config.Conf.BlogApp.General.StartID { - // responseNotice(c, NoticeNotice, "参数错误", "") - // return - // } - // ids = append(ids, int32(i)) - // } - // err := DelArticles(ids...) - // if err != nil { - // logd.Error(err) - // responseNotice(c, NOTICE_NOTICE, err.Error(), "") - // return - // } - // - // // elasticsearch - // err = ElasticDelIndex(ids) - // if err != nil { - // logrus.Error("handleAPIPostDelete.") - // } - // // TODO disqus delete - // responseNotice(c, NoticeSuccess, "删除成功", "") + var ids []int + for _, v := range c.PostFormArray("cid[]") { + id, err := strconv.Atoi(v) + if err != nil || id < config.Conf.BlogApp.General.StartID { + responseNotice(c, NoticeNotice, "参数错误", "") + return + } + ids = append(ids, id) + } + err := cache.Ei.DeleteArticles(ids) + if err != nil { + logrus.Error("handleAPIPostDelete.DeleteArticles: ", err) + + responseNotice(c, NoticeNotice, err.Error(), "") + return + } + // elasticsearch + err = internal.ElasticDelIndex(ids) + if err != nil { + logrus.Error("handleAPIPostDelete.ElasticDelIndex: ", err) + } + // TODO disqus delete + responseNotice(c, NoticeSuccess, "删除成功", "") +} + +// handleAPIPostCreate 创建文章 +func handleAPIPostCreate(c *gin.Context) { + var ( + err error + do string + cid int + ) + defer func() { + switch do { + case "auto": // 自动保存 + if err != nil { + c.JSON(http.StatusOK, gin.H{"fail": 1, "time": time.Now().Format("15:04:05 PM"), "cid": cid}) + return + } + c.JSON(http.StatusOK, gin.H{"success": 0, "time": time.Now().Format("15:04:05 PM"), "cid": cid}) + case "save", "publish": // 草稿,发布 + if err != nil { + responseNotice(c, NoticeNotice, err.Error(), "") + return + } + uri := "/admin/manage-draft" + if do == "publish" { + uri = "/admin/manage-posts" + } + c.Redirect(http.StatusFound, uri) + } + }() + + do = c.PostForm("do") // auto or save or publish + slug := c.PostForm("slug") + title := c.PostForm("title") + text := c.PostForm("text") + date := parseLocationDate(c.PostForm("date")) + serie := c.PostForm("serie") + tag := c.PostForm("tags") + update := c.PostForm("update") + if slug == "" || title == "" || text == "" { + err = errors.New("参数错误") + return + } + serieid, _ := strconv.Atoi(serie) + article := &model.Article{ + Title: title, + Content: text, + Slug: slug, + IsDraft: do != "publish", + Author: cache.Ei.Account.Username, + SerieID: serieid, + Tags: tag, + CreatedAt: date, + } + cid, err = strconv.Atoi(c.PostForm("cid")) + // 新文章 + if err != nil || cid < 1 { + err = cache.Ei.AddArticle(article) + if err != nil { + logrus.Error("handleAPIPostCreate.InsertArticle: ", err) + return + } + if !article.IsDraft { + // 异步执行,快 + go func() { + // elastic + internal.ElasticAddIndex(article) + // rss + internal.PingFunc(cache.Ei.Blogger.BTitle, slug) + // disqus + internal.ThreadCreate(article, cache.Ei.Blogger.BTitle) + }() + } + return + } + // 旧文章 + article.ID = cid + artc, _ := cache.Ei.FindArticleByID(article.ID) + if artc != nil { + article.IsDraft = false + article.Count = artc.Count + article.UpdatedAt = artc.UpdatedAt + } + if update == "true" || update == "1" { + artc.UpdatedAt = time.Now() + } + // 数据库更新 + err = cache.Ei.UpdateArticle(context.Background(), artc.ID, map[string]interface{}{ + "title": article.Title, + "content": article.Content, + "serie_id": article.SerieID, + "tags": article.Tags, + "is_draft": article.IsDraft, + "updated_at": article.UpdatedAt, + "created_at": article.CreatedAt, + }) + if err != nil { + logrus.Error("handleAPIPostCreate.UpdateArticle: ", err) + return + } + if !artc.IsDraft { + cache.Ei.ReplaceArticle(artc, article) + // 异步执行,快 + go func() { + // elastic + internal.ElasticAddIndex(article) + // rss + internal.PingFunc(cache.Ei.Blogger.BTitle, slug) + // disqus + if artc == nil { + internal.ThreadCreate(article, cache.Ei.Blogger.BTitle) + } + }() + } +} + +// handleAPISerieDelete 只能逐一删除,专题下不能有文章 +func handleAPISerieDelete(c *gin.Context) { + for _, v := range c.PostFormArray("mid[]") { + id, err := strconv.Atoi(v) + if err != nil || id < 1 { + responseNotice(c, NoticeNotice, err.Error(), "") + return + } + for i, serie := range cache.Ei.Series { + if serie.ID == id { + if len(serie.Articles) > 0 { + logrus.Error("handleAPISerieDelete.failed: ") + responseNotice(c, NoticeNotice, "请删除该专题下的所有文章", "") + return + } + err = cache.Ei.RemoveSerie(context.Background(), id) + if err != nil { + logrus.Error("handleAPISerieDelete.RemoveSerie: ") + responseNotice(c, NoticeNotice, err.Error(), "") + return + } + cache.Ei.Series[i] = nil + cache.Ei.Series = append(cache.Ei.Series[:i], cache.Ei.Series[i+1:]...) + cache.PagesCh <- cache.PageSeries + break + } + } + } + responseNotice(c, NoticeSuccess, "删除成功", "") +} + +// handleAPISerieCreate 添加专题,如果专题有提交 mid 即更新专题 +func handleAPISerieCreate(c *gin.Context) { + name := c.PostForm("name") + slug := c.PostForm("slug") + desc := c.PostForm("description") + if name == "" || slug == "" || desc == "" { + responseNotice(c, NoticeNotice, "参数错误", "") + return + } + mid, err := strconv.Atoi(c.PostForm("mid")) + if err == nil && mid > 0 { + var serie *model.Serie + for _, v := range cache.Ei.Series { + if v.ID == mid { + serie = v + break + } + } + if serie == nil { + responseNotice(c, NoticeNotice, "专题不存在", "") + return + } + err = cache.Ei.UpdateSerie(context.Background(), mid, map[string]interface{}{ + "slug": slug, + "name": name, + "desc": desc, + }) + if err != nil { + logrus.Error("handleAPISerieCreate.UpdateSerie: ", err) + responseNotice(c, NoticeNotice, err.Error(), "") + return + } + } else { + err = cache.Ei.InsertSerie(context.Background(), &model.Serie{ + Slug: slug, + Name: name, + Desc: desc, + }) + if err != nil { + logrus.Error("handleAPISerieCreate.InsertSerie: ", err) + responseNotice(c, NoticeNotice, err.Error(), "") + return + } + } + responseNotice(c, NoticeSuccess, "操作成功", "") +} + +// parseLocationDate 解析日期 +func parseLocationDate(date string) time.Time { + t, err := time.ParseInLocation("2006-01-02 15:04", date, time.Local) + if err == nil { + return t + } + return time.Now() } func responseNotice(c *gin.Context, typ, content, hl string) { diff --git a/pkg/core/blog/page/be.go b/pkg/core/blog/page/be.go index 9ab02af..d627c5b 100644 --- a/pkg/core/blog/page/be.go +++ b/pkg/core/blog/page/be.go @@ -73,7 +73,7 @@ func handleAdminPost(c *gin.Context) { params["Domain"] = config.Conf.BlogApp.Host params["Series"] = cache.Ei.Series var tags []T - for tag, _ := range cache.Ei.TagArticles { + for tag := range cache.Ei.TagArticles { tags = append(tags, T{tag, tag}) } str, _ := json.Marshal(tags) diff --git a/pkg/internal/es.go b/pkg/internal/es.go index adcef7b..6250d1f 100644 --- a/pkg/internal/es.go +++ b/pkg/internal/es.go @@ -13,6 +13,8 @@ import ( "time" "github.com/eiblog/eiblog/pkg/config" + "github.com/eiblog/eiblog/pkg/model" + "github.com/eiblog/eiblog/tools" "github.com/sirupsen/logrus" ) @@ -22,6 +24,9 @@ const ( SearchFilter = `"filter":{"bool":{"must":[%s]}}` SearchTerm = `{"term":{"%s":"%s"}}` SearchDate = `{"range":{"date":{"gte":"%s","lte": "%s","format": "yyyy-MM-dd||yyyy-MM||yyyy"}}}` // 2016-10||/M + + ElasticIndex = "eiblog" + ElasticType = "article" ) func init() { @@ -30,7 +35,7 @@ func init() { } mappings := fmt.Sprintf(`{"mappings":{"%s":{"properties":{"content":{"analyzer":"ik_syno","search_analyzer":"ik_syno","term_vector":"with_positions_offsets","type":"string"},"date":{"index":"not_analyzed","type":"date"},"slug":{"type":"string"},"tag":{"index":"not_analyzed","type":"string"},"title":{"analyzer":"ik_syno","search_analyzer":"ik_syno","term_vector":"with_positions_offsets","type":"string"}}}}}`, "article") - err := createIndexAndMappings("eiblog", "article", []byte(mappings)) + err := createIndexAndMappings(ElasticIndex, ElasticType, []byte(mappings)) if err != nil { panic(err) } @@ -97,7 +102,40 @@ func ElasticSearch(query string, size, from int) (*searchIndexResult, error) { if kw != "" { dsl = strings.Replace(strings.Replace(`{"highlight":{"fields":{"content":{},"title":{}},"post_tags":["\u003c/b\u003e"],"pre_tags":["\u003cb\u003e"]},"query":{"dis_max":{"queries":[{"match":{"title":{"boost":4,"minimum_should_match":"50%","query":"$1"}}},{"match":{"content":{"boost":4,"minimum_should_match":"75%","query":"$1"}}},{"match":{"tag":{"boost":2,"minimum_should_match":"100%","query":"$1"}}},{"match":{"slug":{"boost":1,"minimum_should_match":"100%","query":"$1"}}}],"tie_breaker":0.3}},$2}`, "$1", kw, -1), "$2", fmt.Sprintf(SearchFilter, strings.Join(filter, ",")), -1) } - return indexQueryDSL("article", "eiblog", size, from, []byte(dsl)) + return indexQueryDSL(ElasticIndex, ElasticType, size, from, []byte(dsl)) +} + +// ElasticAddIndex 添加或更新索引 +func ElasticAddIndex(article *model.Article) error { + if err := checkESConfig(); err != nil { + return err + } + + tags := strings.Split(article.Tags, ",") + img := tools.PickFirstImage(article.Content) + mapping := map[string]interface{}{ + "title": article.Title, + "content": tools.IgnoreHtmlTag(article.Content), + "slug": article.Slug, + "tag": tags, + "img": img, + "date": article.CreatedAt, + } + data, _ := json.Marshal(mapping) + return indexOrUpdateDocument(ElasticIndex, ElasticType, article.ID, data) +} + +// ElasticDelIndex 删除索引 +func ElasticDelIndex(ids []int) error { + if err := checkESConfig(); err != nil { + return err + } + + var target []string + for _, id := range ids { + target = append(target, fmt.Sprint(id)) + } + return deleteIndexDocument(ElasticIndex, ElasticType, target) } // indicesCreateResult 索引创建结果 @@ -139,7 +177,7 @@ func createIndexAndMappings(index, typ string, mappings []byte) error { } // indexOrUpdateDocument 创建或更新索引 -func indexOrUpdateDocument(index, typ string, id int32, doc []byte) (err error) { +func indexOrUpdateDocument(index, typ string, id int, doc []byte) (err error) { rawurl := fmt.Sprintf("%s/%s/%s/%d", config.Conf.ESHost, index, typ, id) resp, err := httpPut(rawurl, doc) if err != nil { diff --git a/pkg/model/account.go b/pkg/model/account.go index 5e59ac2..15a2fb5 100644 --- a/pkg/model/account.go +++ b/pkg/model/account.go @@ -13,9 +13,9 @@ type Account struct { PhoneN string `gorm:"column:phone_n;not null" bson:"phone_n"` // 手机号 Address string `gorm:"column:address;not null" bson:"address"` // 地址信息 - LogoutAt time.Time `gorm:"column:logout_at;default:null" bson:"logout_at"` // 登出时间 - LoginIP string `gorm:"column:login_ip;default:null" bson:"login_ip"` // 最近登录IP - LoginUA string `gorm:"column:login_ua;default:null" bson:"login_ua"` // 最近登录IP - LoginAt time.Time `gorm:"column:login_at;default:now()" bson:"login_at"` // 最近登录时间 - CreatedAt time.Time `gorm:"column:creatd_at;default:now()" bson:"created_at"` // 创建时间 + LogoutAt time.Time `gorm:"column:logout_at;default:null" bson:"logout_at"` // 登出时间 + LoginIP string `gorm:"column:login_ip;default:null" bson:"login_ip"` // 最近登录IP + LoginUA string `gorm:"column:login_ua;default:null" bson:"login_ua"` // 最近登录IP + LoginAt time.Time `gorm:"column:login_at;default:now()" bson:"login_at"` // 最近登录时间 + CreatedAt time.Time `gorm:"column:created_at;default:now()" bson:"created_at"` // 创建时间 } diff --git a/tools/tools.go b/tools/tools.go index ef54b65..1597809 100644 --- a/tools/tools.go +++ b/tools/tools.go @@ -7,6 +7,7 @@ import ( "io" "io/ioutil" "path" + "regexp" "time" ) @@ -102,3 +103,27 @@ var monthToDays = map[time.Month]int{ func isLeapYear(year int) bool { return year%4 == 0 && (year%100 != 0 || year%400 == 0) } + +var regexpImg = regexp.MustCompile(`data-src="(.*?)"`) + +// PickFirstImage 获取第一张图片 +func PickFirstImage(html string) string { + sli := regexpImg.FindAllStringSubmatch(html, 1) + if len(sli) > 0 && len(sli[0]) > 1 { + return sli[0][1] + } + return "" +} + +var ( + regexpBrackets = regexp.MustCompile(`<[\S\s]+?>`) + regexpEnter = regexp.MustCompile(`\s+`) +) + +// IgnoreHtmlTag 去掉 html tag +func IgnoreHtmlTag(src string) string { + // 去除所有尖括号内的HTML代码 + src = regexpBrackets.ReplaceAllString(src, "") + // 去除换行符 + return regexpEnter.ReplaceAllString(src, "") +} diff --git a/tools/validate.go b/tools/validate.go index c96c5c1..ad957b6 100644 --- a/tools/validate.go +++ b/tools/validate.go @@ -1,7 +1,9 @@ // Package tools provides ... package tools -import "regexp" +import ( + "regexp" +) var regexpEmail = regexp.MustCompile(`^(\w)+([\.\-]\w+)*@(\w)+((\.\w+)+)$`)