service.xpcool.com/internal/service/notice/notice.go
夏犀麟 ad4567fc01 feat: 招聘爬虫健壮性 + 通知记录增强(归档工作区留存改动)
这两批改动此前一直压在工作区未提交,本次一并归档。go build ./... 通过。

一、招聘爬虫健壮性
- 新增 internal/service/recruitment/source_config.go:
  crawl_source.config(VARCHAR(1000) JSON)的源级抓取参数解析 —— 增量窗口天数、
  单次详情页上限、最大翻页数、详情间隔限速、自定义标准词/排除词。
  全部字段可选,JSON 解析失败即回退默认值,保证历史数据零迁移可用,
  且一个源的脏配置不会拖垮整个调度。
- 新增 internal/service/recruitment/keywords.go(含 keywords_test.go 与 testdata/):
  链接筛选由「URL 形态命中 AND 文本语义命中」改为评分制 —— URL 形态加分、
  标准词加分、排除词大幅减分,达阈值即入选。原与逻辑会系统性漏抓
  「补充工作人员的通知」「公开选调公务员简章」等标题变体,且静默无报错。
- 新增 manifest/sql/recruitment/004_crawl_source_status.sql:
  crawl_source 增加「最近一次运行状态」冗余列,抓取结束时写入,
  使 Sources() 查询零 JOIN 零扫描 —— 规避原实现全表扫描只增不减的 crawl_log、
  随运行时间线性劣化的问题。幂等,可重复执行。
- internal/service/recruitment/crawler.go:在既有「静态两级 / SPA / curl 回退」
  流程上做锚点抽取与过滤、编码回退的健壮性增强。
- internal/service/recruitment/bark.go、recruitment.go:配合上述调整。

二、通知记录增强
- api/notice、internal/controller/notice、internal/service/notice、
  internal/model/{dto,entity,do}/notice.go:通知日志查询与操作扩展 ——
  批次号、状态、重试次数、来源、耗时、详情、删除、清空。
- 新增 internal/model/dto/notice_meta.go(字典选项元数据,供前端筛选器取选项)。
- 新增 manifest/sql/017_notice_log_enhance.sql:notice_log 扩展列 +
  删除/清空/详情/字典选项接口权限,幂等(ALTER 走 information_schema 判断,
  菜单 ON DUPLICATE KEY UPDATE,角色绑定 INSERT IGNORE)。

三、仓库卫生
- .gitignore 补 rc_*.log 与 rc_verify*,挡掉本地离线验证产物
  (含 30MB 的 rc_verify.exe),避免误入库。

设计依据见 docs/recruitment-crawler-design.md。

注:017 与 recruitment/004 两个 SQL 为增量迁移,DB 结构变更不自动 DDL,
生产库需手动导入。
2026-09-14 01:22:39 +08:00

623 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package notice 提供统一通知服务(渠道/规则/发送/历史记录)。
// 发送实现Bark 走路径式 keyPOST {baseUrl}/{deviceKey} JSONpushplus 走官方 send 接口。
package notice
import (
"context"
"encoding/json"
"strings"
"time"
"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/gogf/gf/v2/util/guid"
"service.xpcool.com/internal/consts"
"service.xpcool.com/internal/dao"
"service.xpcool.com/internal/library/response"
"service.xpcool.com/internal/model/do"
"service.xpcool.com/internal/model/dto"
"service.xpcool.com/internal/model/entity"
)
// gdbModel 查询模型别名,避免在多处书写完整包路径。
type gdbModel = *gdb.Model
// INotice 统一通知服务接口。
type INotice interface {
ChannelList(context.Context) ([]dto.NoticeChannelVO, error)
ChannelSave(context.Context, dto.NoticeChannelInput) (uint64, error)
RuleList(context.Context, string) ([]dto.NoticeRuleVO, error)
RuleSave(context.Context, dto.NoticeRuleInput) (uint64, error)
RuleDelete(context.Context, uint64) error
// LogList 通知历史记录分页(返回列表、总数与统计概览)。
LogList(context.Context, dto.NoticeLogFilter) ([]dto.NoticeLogVO, int, *dto.NoticeLogStats, error)
LogDetail(context.Context, uint64) (*dto.NoticeLogVO, error)
LogDelete(context.Context, []uint64) (int, error)
LogClear(context.Context, int, string, string) (int, error)
Send(context.Context, string, map[string]string) error // 按启用规则发送通知(事件→渠道→人员)
Test(context.Context, uint64, string, string, []uint64, string, string) (bool, string, error)
}
type notice struct{}
var localNotice INotice
// New 创建通知服务实现。
func New() INotice { return &notice{} }
// Notice 返回已注册的通知服务实现。
func Notice() INotice {
if localNotice == nil {
panic("Notice 实现未注册")
}
return localNotice
}
// RegisterNotice 注册通知服务实现。
func RegisterNotice(i INotice) { localNotice = i }
// ---------------- 渠道 ----------------
func (s *notice) ChannelList(ctx context.Context) ([]dto.NoticeChannelVO, error) {
var list []entity.NoticeChannel
if err := dao.NoticeChannel.Ctx(ctx).OrderAsc("id").Scan(&list); err != nil {
return nil, gerror.Wrap(err, "查询通知渠道失败")
}
out := make([]dto.NoticeChannelVO, 0, len(list))
for _, v := range list {
out = append(out, dto.NoticeChannelVO{Id: v.Id, Code: v.Code, Name: v.Name, Enabled: v.Enabled, Config: v.Config, Remark: v.Remark})
}
return out, nil
}
func (s *notice) ChannelSave(ctx context.Context, in dto.NoticeChannelInput) (uint64, error) {
data := do.NoticeChannel{
Code: in.Code, Name: in.Name, Enabled: in.Enabled,
Config: in.Config, Remark: in.Remark,
}
if in.Id > 0 {
if _, err := dao.NoticeChannel.Ctx(ctx).Where("id", in.Id).Data(data).Update(); err != nil {
return 0, gerror.Wrap(err, "更新通知渠道失败")
}
return in.Id, nil
}
id, err := dao.NoticeChannel.Ctx(ctx).Data(data).InsertAndGetId()
if err != nil {
return 0, gerror.Wrap(err, "新增通知渠道失败")
}
return uint64(id), nil
}
// ---------------- 规则 ----------------
func (s *notice) RuleList(ctx context.Context, eventType string) ([]dto.NoticeRuleVO, error) {
m := dao.NoticeRule.Ctx(ctx)
if eventType != "" {
m = m.Where("event_type", eventType)
}
var list []entity.NoticeRule
if err := m.OrderAsc("id").Scan(&list); err != nil {
return nil, gerror.Wrap(err, "查询通知规则失败")
}
out := make([]dto.NoticeRuleVO, 0, len(list))
for _, v := range list {
out = append(out, dto.NoticeRuleVO{
Id: v.Id, Name: v.Name, EventType: v.EventType,
ChannelCodes: parseStrArray(v.ChannelCodes), UserIds: parseUintArray(v.UserIds),
TitleTemplate: v.TitleTemplate, BodyTemplate: v.BodyTemplate, Enabled: v.Enabled,
})
}
return out, nil
}
func (s *notice) RuleSave(ctx context.Context, in dto.NoticeRuleInput) (uint64, error) {
data := do.NoticeRule{
Name: in.Name, EventType: in.EventType,
ChannelCodes: toJSON(in.ChannelCodes), UserIds: toJSON(in.UserIds),
TitleTemplate: in.TitleTemplate, BodyTemplate: in.BodyTemplate, Enabled: in.Enabled,
}
if in.Id > 0 {
if _, err := dao.NoticeRule.Ctx(ctx).Where("id", in.Id).Data(data).Update(); err != nil {
return 0, gerror.Wrap(err, "更新通知规则失败")
}
return in.Id, nil
}
id, err := dao.NoticeRule.Ctx(ctx).Data(data).InsertAndGetId()
if err != nil {
return 0, gerror.Wrap(err, "新增通知规则失败")
}
return uint64(id), nil
}
func (s *notice) RuleDelete(ctx context.Context, id uint64) error {
if _, err := dao.NoticeRule.Ctx(ctx).Where("id", id).Delete(); err != nil {
return gerror.Wrap(err, "删除通知规则失败")
}
return nil
}
// ---------------- 历史记录 ----------------
// LogList 通知历史记录分页:支持关键字、类型、分组、事件、渠道、接收人、状态、时间范围、排序。
func (s *notice) LogList(ctx context.Context, f dto.NoticeLogFilter) ([]dto.NoticeLogVO, int, *dto.NoticeLogStats, error) {
f.Normalize()
m := s.logQuery(ctx, f)
total, err := m.Clone().Count()
if err != nil {
return nil, 0, nil, gerror.Wrap(err, "统计通知历史记录总数失败")
}
// 统计概览(成功/失败/成功率),与列表使用同一套筛选条件。
stats, err := s.logStats(ctx, f, total)
if err != nil {
return nil, 0, nil, err
}
var list []entity.NoticeLog
orderField := "created_at"
if f.OrderBy == "durationMs" {
orderField = "duration_ms"
}
if f.OrderDir == "asc" {
err = m.Clone().Page(f.Page, f.Size).OrderAsc(orderField).OrderAsc("id").Scan(&list)
} else {
err = m.Clone().Page(f.Page, f.Size).OrderDesc(orderField).OrderDesc("id").Scan(&list)
}
if err != nil {
return nil, 0, nil, gerror.Wrap(err, "查询通知历史记录列表失败")
}
out := s.buildLogVOs(ctx, list)
return out, total, stats, nil
}
// logQuery 组装历史记录筛选条件(列表与统计共用,保证口径一致)。
func (s *notice) logQuery(ctx context.Context, f dto.NoticeLogFilter) gdbModel {
m := dao.NoticeLog.Ctx(ctx)
if f.Keyword != "" {
kw := "%" + f.Keyword + "%"
m = m.Where("title LIKE ? OR body LIKE ? OR target LIKE ? OR error LIKE ?", kw, kw, kw, kw)
}
if f.EventType != "" {
m = m.Where("event_type", f.EventType)
} else if f.Group != "" {
if events := dto.NoticeGroupEventCodes(f.Group); len(events) > 0 {
m = m.WhereIn("event_type", events)
} else {
// 未在字典中的分组(如 system则反向排除已知事件。
m = m.WhereNotIn("event_type", dto.KnownEventCodes())
}
} else if f.NoticeType != "" {
switch f.NoticeType {
case dto.NoticeTypeSystem:
m = m.WhereNotIn("event_type", dto.KnownEventCodes())
default:
if events := dto.NoticeTypeEventCodes(f.NoticeType); len(events) > 0 {
m = m.WhereIn("event_type", events)
}
}
}
if f.ChannelCode != "" {
m = m.Where("channel_code", f.ChannelCode)
}
if f.UserId > 0 {
m = m.Where("user_id", f.UserId)
}
if f.BatchId != "" {
m = m.Where("batch_id", f.BatchId)
}
if f.Status > 0 {
if f.Status == dto.NoticeStatusSuccess {
// 成功status=1 或历史数据 result=1。
m = m.Where("(status = 1 OR (status = 0 AND result = 1))")
} else {
// 失败status=2 或历史数据 result=0且非待发送
m = m.Where("(status = 2 OR (status = 0 AND result = 0))")
}
}
if f.DateFrom != "" {
m = m.WhereGTE("created_at", f.DateFrom)
}
if f.DateTo != "" {
m = m.WhereLTE("created_at", f.DateTo)
}
return m
}
// logStats 统计概览:总数、成功数、失败数、成功率。
func (s *notice) logStats(ctx context.Context, f dto.NoticeLogFilter, total int) (*dto.NoticeLogStats, error) {
stats := &dto.NoticeLogStats{Total: total}
successFilter := f
successFilter.Status = dto.NoticeStatusSuccess
if n, err := s.logQuery(ctx, successFilter).Clone().Count(); err != nil {
return nil, gerror.Wrap(err, "统计通知成功数失败")
} else {
stats.Success = n
}
stats.Failed = total - stats.Success
if stats.Failed < 0 {
stats.Failed = 0
}
if total > 0 {
stats.SuccessRate = int(float64(stats.Success)*100/float64(total) + 0.5)
}
return stats, nil
}
// buildLogVOs 实体转 VO并批量补齐规则名 / 接收人 等派生字段(避免 N+1
func (s *notice) buildLogVOs(ctx context.Context, list []entity.NoticeLog) []dto.NoticeLogVO {
out := make([]dto.NoticeLogVO, 0, len(list))
if len(list) == 0 {
return out
}
// 批量取规则名称
ruleIds := make([]uint64, 0, len(list))
userIds := make([]uint64, 0, len(list))
seenRule := map[uint64]struct{}{}
seenUser := map[uint64]struct{}{}
for _, v := range list {
if v.RuleId > 0 {
if _, ok := seenRule[v.RuleId]; !ok {
seenRule[v.RuleId] = struct{}{}
ruleIds = append(ruleIds, v.RuleId)
}
}
if v.UserId > 0 {
if _, ok := seenUser[v.UserId]; !ok {
seenUser[v.UserId] = struct{}{}
userIds = append(userIds, v.UserId)
}
}
}
ruleNames := map[uint64]string{}
if len(ruleIds) > 0 {
var rules []entity.NoticeRule
if err := dao.NoticeRule.Ctx(ctx).WhereIn("id", ruleIds).Scan(&rules); err == nil {
for _, r := range rules {
ruleNames[r.Id] = r.Name
}
}
}
userNames := map[uint64]string{}
if len(userIds) > 0 {
var users []entity.AdminUser
if err := dao.AdminUser.Ctx(ctx).WhereIn("id", userIds).Fields("id, username, nickname").Scan(&users); err == nil {
for _, u := range users {
name := u.Nickname
if name == "" {
name = u.Username
}
userNames[u.Id] = name
}
}
}
for _, v := range list {
out = append(out, toLogVO(v, ruleNames[v.RuleId], userNames[v.UserId]))
}
return out
}
// toLogVO 单条实体转 VO补齐派生名称字段
func toLogVO(v entity.NoticeLog, ruleName, userName string) dto.NoticeLogVO {
noticeType := dto.NoticeEventToType(v.EventType)
group := dto.NoticeTypeToGroup(noticeType)
// status 兼容历史数据:仅有 result 时按 result 推导。
status := v.Status
if status == 0 && v.Result == 1 {
status = dto.NoticeStatusSuccess
} else if status == 0 && v.Result == 0 && v.CreatedAt != "" && v.Error != "" {
status = dto.NoticeStatusFailed
}
return dto.NoticeLogVO{
Id: v.Id, BatchId: v.BatchId, RuleId: v.RuleId, RuleName: ruleName,
EventType: v.EventType, EventName: dto.NoticeEventName(v.EventType),
NoticeType: noticeType, TypeName: dto.NoticeTypeName(noticeType),
Group: group, GroupName: dto.NoticeGroupName(group),
ChannelCode: v.ChannelCode, ChannelName: dto.NoticeChannelName(v.ChannelCode),
UserId: v.UserId, UserName: userName, Target: v.Target,
Title: v.Title, Body: v.Body,
Status: status, StatusName: dto.NoticeStatusName(status),
Result: v.Result, Error: v.Error,
RetryCount: v.RetryCount, DurationMs: v.DurationMs,
Source: v.Source, Remark: v.Remark, CreatedAt: v.CreatedAt,
}
}
// LogDetail 单条历史记录详情。
func (s *notice) LogDetail(ctx context.Context, id uint64) (*dto.NoticeLogVO, error) {
var v entity.NoticeLog
if err := dao.NoticeLog.Ctx(ctx).Where("id", id).Scan(&v); err != nil {
return nil, gerror.Wrap(err, "查询通知历史详情失败")
}
if v.Id == 0 {
return nil, response.Error(consts.CodeInvalidParam, "通知记录不存在")
}
ruleName := ""
if v.RuleId > 0 {
var r entity.NoticeRule
_ = dao.NoticeRule.Ctx(ctx).Where("id", v.RuleId).Scan(&r)
ruleName = r.Name
}
userName := ""
if v.UserId > 0 {
var u entity.AdminUser
_ = dao.AdminUser.Ctx(ctx).Where("id", v.UserId).Fields("id, username, nickname").Scan(&u)
userName = u.Nickname
if userName == "" {
userName = u.Username
}
}
vo := toLogVO(v, ruleName, userName)
return &vo, nil
}
// LogDelete 批量删除历史记录,返回删除条数。
func (s *notice) LogDelete(ctx context.Context, ids []uint64) (int, error) {
if len(ids) == 0 {
return 0, response.Error(consts.CodeInvalidParam, "请选择要删除的记录")
}
r, err := dao.NoticeLog.Ctx(ctx).WhereIn("id", ids).Delete()
if err != nil {
return 0, gerror.Wrap(err, "删除通知历史记录失败")
}
n, _ := r.RowsAffected()
return int(n), nil
}
// LogClear 清空历史记录KeepDays>0 时保留最近 N 天,否则按 DateFrom/DateTo 范围删除(都为空则全清)。
func (s *notice) LogClear(ctx context.Context, keepDays int, dateFrom, dateTo string) (int, error) {
m := dao.NoticeLog.Ctx(ctx)
switch {
case keepDays > 0:
cut := gtime.Now().AddDate(0, 0, -keepDays).Format("Y-m-d H:i:s")
m = m.WhereLT("created_at", cut)
case dateFrom != "" || dateTo != "":
if dateFrom != "" {
m = m.WhereGTE("created_at", dateFrom)
}
if dateTo != "" {
m = m.WhereLTE("created_at", dateTo)
}
default:
m = m.Where("id > ?", 0) // 全清
}
r, err := m.Delete()
if err != nil {
return 0, gerror.Wrap(err, "清空通知历史记录失败")
}
n, _ := r.RowsAffected()
return int(n), nil
}
// ---------------- 发送 ----------------
// Send 按事件类型查启用规则,向每个渠道的每个接收用户发送,并记录日志。
// 同一次 Send 产生的多条投递共用同一批次号batchId便于按批次追溯。
func (s *notice) Send(ctx context.Context, eventType string, vars map[string]string) error {
var rules []entity.NoticeRule
if err := dao.NoticeRule.Ctx(ctx).Where("event_type", eventType).Where("enabled", 1).Scan(&rules); err != nil {
return gerror.Wrap(err, "查询待发送通知规则失败")
}
if len(rules) == 0 {
return nil // 未配置规则 = 静默
}
batchId := newBatchId()
for _, r := range rules {
channels := parseStrArray(r.ChannelCodes)
userIds := parseUintArray(r.UserIds)
title := dto.RenderTemplate(r.TitleTemplate, vars)
body := dto.RenderTemplate(r.BodyTemplate, vars)
for _, channel := range channels {
for _, uid := range userIds {
s.deliver(ctx, deliverInput{
BatchId: batchId, RuleId: r.Id, EventType: eventType,
Channel: channel, UserId: uid, Title: title, Body: body,
Source: "auto",
})
}
}
}
return nil
}
// deliverInput 单条投递入参。
type deliverInput struct {
BatchId string
RuleId uint64
EventType string
Channel string
UserId uint64
Title string
Body string
Source string // auto/manual/test
Remark string
}
// deliver 单条投递:取用户渠道凭据 → 发送 → 记日志(含耗时与来源)。
func (s *notice) deliver(ctx context.Context, in deliverInput) {
var user entity.AdminUser
_ = dao.AdminUser.Ctx(ctx).Where("id", in.UserId).Scan(&user)
var target string
switch in.Channel {
case "bark":
target = user.BarkDeviceId
case "pushplus":
target = user.PushplusToken
default:
target = ""
}
start := time.Now()
ok, errMsg := s.sendByChannel(ctx, in.Channel, target, in.Title, in.Body)
duration := int(time.Since(start).Milliseconds())
status := dto.NoticeStatusFailed
if ok {
status = dto.NoticeStatusSuccess
}
_, _ = dao.NoticeLog.Ctx(ctx).Data(do.NoticeLog{
BatchId: in.BatchId, RuleId: in.RuleId, EventType: in.EventType,
ChannelCode: in.Channel, UserId: in.UserId, Target: target,
Title: in.Title, Body: in.Body,
Status: status, Result: boolToInt(ok), Error: errMsg,
DurationMs: duration, Source: in.Source, Remark: in.Remark,
}).Insert()
}
// sendByChannel 按渠道发送单条;返回 (是否成功, 错误信息)。
func (s *notice) sendByChannel(ctx context.Context, channel, target, title, body string) (bool, string) {
if target == "" {
return false, "目标凭据为空(用户未配置)"
}
client := g.Client().SetTimeout(10 * time.Second)
switch channel {
case "bark":
// 渠道配置取 baseUrlJSON config.baseUrl默认容器网络内 bark:8080
baseURL := "http://bark:8080"
var ch entity.NoticeChannel
if err := dao.NoticeChannel.Ctx(ctx).Where("code", "bark").Scan(&ch); err == nil && ch.Config != "" {
var cfg struct {
BaseURL string `json:"baseUrl"`
}
_ = json.Unmarshal([]byte(ch.Config), &cfg)
if cfg.BaseURL != "" {
baseURL = cfg.BaseURL
}
}
// 路径式 keyPOST {baseUrl}/{deviceKey}body JSON {title, body}
url := strings.TrimRight(baseURL, "/") + "/" + target
resp, err := client.Post(ctx, url, map[string]string{"title": title, "body": body})
if err != nil {
return false, err.Error()
}
if resp.StatusCode != 200 {
return false, "bark http " + resp.Status
}
var out struct {
Code int `json:"code"`
}
respBody := resp.ReadAllString()
_ = json.Unmarshal([]byte(respBody), &out)
if out.Code != 200 {
return false, "bark code " + respBody
}
return true, ""
case "pushplus":
// 官方接口POST https://www.pushplus.plus/send {token,title,content}
resp, err := client.Post(ctx, "https://www.pushplus.plus/send", map[string]string{
"token": target, "title": title, "content": body,
})
if err != nil {
return false, err.Error()
}
if resp.StatusCode != 200 {
return false, "pushplus http " + resp.Status
}
var out struct {
Code int `json:"code"`
}
respBody := resp.ReadAllString()
_ = json.Unmarshal([]byte(respBody), &out)
if out.Code != 200 {
return false, "pushplus code " + respBody
}
return true, ""
}
return false, "未知渠道: " + channel
}
// Test 测试发送:优先按规则;否则手动指定渠道+目标。
func (s *notice) Test(ctx context.Context, ruleId uint64, channelCode, target string, userIds []uint64, title, body string) (bool, string, error) {
batchId := newBatchId()
if ruleId > 0 {
var r entity.NoticeRule
if err := dao.NoticeRule.Ctx(ctx).Where("id", ruleId).Scan(&r); err != nil || r.Id == 0 {
return false, "", response.Error(consts.CodeInvalidParam, "规则不存在")
}
for _, ch := range parseStrArray(r.ChannelCodes) {
for _, uid := range parseUintArray(r.UserIds) {
s.deliver(ctx, deliverInput{
BatchId: batchId, RuleId: r.Id, EventType: dto.NoticeEventTest,
Channel: ch, UserId: uid, Title: title, Body: body,
Source: "test", Remark: "按规则测试",
})
}
}
return true, "已按规则投递", nil
}
// 手动:渠道+目标target 为空则按用户)
if channelCode == "" {
return false, "", response.Error(consts.CodeInvalidParam, "请指定渠道")
}
userId := uint64(0)
if target == "" && len(userIds) > 0 {
userId = userIds[0]
var user entity.AdminUser
_ = dao.AdminUser.Ctx(ctx).Where("id", userId).Scan(&user)
if channelCode == "bark" {
target = user.BarkDeviceId
} else if channelCode == "pushplus" {
target = user.PushplusToken
}
}
if target == "" {
return false, "", response.Error(consts.CodeInvalidParam, "目标凭据为空")
}
start := time.Now()
ok, msg := s.sendByChannel(ctx, channelCode, target, title, body)
duration := int(time.Since(start).Milliseconds())
status := dto.NoticeStatusFailed
if ok {
status = dto.NoticeStatusSuccess
}
_, _ = dao.NoticeLog.Ctx(ctx).Data(do.NoticeLog{
BatchId: batchId, RuleId: 0, EventType: dto.NoticeEventTest,
ChannelCode: channelCode, UserId: userId, Target: target,
Title: title, Body: body,
Status: status, Result: boolToInt(ok), Error: msg,
DurationMs: duration, Source: "test", Remark: "手动测试",
}).Insert()
return ok, msg, nil
}
// ---------------- 工具 ----------------
// newBatchId 生成投递批次号(同一次业务触发共用一个)。
func newBatchId() string {
return guid.S()[:16]
}
func parseStrArray(s string) []string {
var out []string
_ = json.Unmarshal([]byte(s), &out)
if out == nil {
out = []string{}
}
return out
}
func parseUintArray(s string) []uint64 {
var out []uint64
_ = json.Unmarshal([]byte(s), &out)
if out == nil {
out = []uint64{}
}
return out
}
func toJSON(v interface{}) string {
b, _ := json.Marshal(v)
return string(b)
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
// NowStr 当前时间字符串(供日志)。
func NowStr() string { return gtime.Now().Format("Y-m-d H:i:s") }