554 lines
18 KiB
Go
554 lines
18 KiB
Go
package tasks
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.118im.com/im_team/im_workbench/internal/store"
|
|
)
|
|
|
|
const (
|
|
defaultPageSize = 20
|
|
maxPageSize = 100
|
|
defaultDeadlineMinute = 15
|
|
)
|
|
|
|
var shanghai = time.FixedZone("Asia/Shanghai", 8*60*60)
|
|
|
|
type Repository interface {
|
|
CurrentSiteConfig(context.Context, int64) (json.RawMessage, error)
|
|
TaskHalls(context.Context, int64, int64) ([]store.TaskHall, error)
|
|
TaskEligibility(context.Context, int64, int64, int64) (*store.TaskEligibility, error)
|
|
ClaimTask(context.Context, store.ClaimTaskInput) (*store.TaskOrder, error)
|
|
TaskOrders(context.Context, int64, int64, int, int, int) (*store.TaskOrderPage, error)
|
|
TaskOrder(context.Context, int64, int64, int64) (*store.TaskOrder, error)
|
|
TaskStats(context.Context, int64, int64) (*store.TaskStats, error)
|
|
SubmitTask(context.Context, store.TaskSubmissionInput) (*store.TaskSubmissionResult, error)
|
|
OrderLedgers(context.Context, int64, int64, []string) ([]map[string]any, error)
|
|
}
|
|
|
|
type Service struct {
|
|
repository Repository
|
|
now func() time.Time
|
|
}
|
|
|
|
type Hall struct {
|
|
ID int64 `json:"id"`
|
|
Code string `json:"code"`
|
|
Name string `json:"name"`
|
|
IconURL string `json:"icon_url"`
|
|
Summary string `json:"summary"`
|
|
CommissionRate int `json:"commission_rate"`
|
|
RateUnit string `json:"rate_unit"`
|
|
ClientRoute string `json:"client_route"`
|
|
}
|
|
|
|
type Eligibility struct {
|
|
Eligible bool `json:"eligible"`
|
|
Reason string `json:"reason"`
|
|
Message string `json:"message"`
|
|
AvailableAmount int64 `json:"available_amount"`
|
|
MinimumAmount int64 `json:"minimum_amount"`
|
|
TodayCount int `json:"today_count"`
|
|
DailyLimit int `json:"daily_limit"`
|
|
PendingOrderID int64 `json:"pending_order_id"`
|
|
BusinessStartAt string `json:"business_start_at"`
|
|
BusinessEndAt string `json:"business_end_at"`
|
|
}
|
|
|
|
type Order struct {
|
|
ID int64 `json:"id"`
|
|
OrderNo string `json:"order_no"`
|
|
TaskID int64 `json:"task_id"`
|
|
Product Product `json:"product"`
|
|
Quantity int `json:"quantity"`
|
|
UnitPriceAmount int64 `json:"unit_price_amount"`
|
|
TotalAmount int64 `json:"total_amount"`
|
|
CommissionAmount int64 `json:"commission_amount"`
|
|
Currency string `json:"currency"`
|
|
Status string `json:"status"`
|
|
StatusText string `json:"status_text"`
|
|
Message string `json:"message"`
|
|
DeadlineAt string `json:"deadline_at"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
Task *TaskDetail `json:"task,omitempty"`
|
|
RuleSnapshot json.RawMessage `json:"rule_snapshot,omitempty"`
|
|
Submission *Submission `json:"submission,omitempty"`
|
|
Timeline []TimelineEntry `json:"timeline,omitempty"`
|
|
Ledgers []map[string]any `json:"ledgers,omitempty"`
|
|
AllowedActions []string `json:"allowed_actions,omitempty"`
|
|
}
|
|
|
|
type Product struct {
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"`
|
|
CoverURL string `json:"cover_url"`
|
|
}
|
|
|
|
type TaskDetail struct {
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
DetailHTML string `json:"detail_html"`
|
|
}
|
|
|
|
type Submission struct {
|
|
SubmissionNo string `json:"submission_no"`
|
|
Status string `json:"status"`
|
|
StatusText string `json:"status_text"`
|
|
EvidenceText string `json:"evidence_text"`
|
|
EvidenceAttachmentIDs []int64 `json:"evidence_attachment_ids"`
|
|
ReviewReasonCode string `json:"review_reason_code"`
|
|
ReviewReason string `json:"review_reason"`
|
|
AllowResubmit bool `json:"allow_resubmit"`
|
|
SubmittedAt string `json:"submitted_at"`
|
|
ReviewedAt string `json:"reviewed_at"`
|
|
SettledAt string `json:"settled_at"`
|
|
}
|
|
|
|
type TimelineEntry struct {
|
|
Status string `json:"status"`
|
|
Text string `json:"text"`
|
|
At string `json:"at"`
|
|
}
|
|
|
|
type OrderPage struct {
|
|
Items []Order `json:"items"`
|
|
Page int `json:"page"`
|
|
PageSize int `json:"page_size"`
|
|
Total int64 `json:"total"`
|
|
}
|
|
|
|
type Stats struct {
|
|
AvailableAmount int64 `json:"available_amount"`
|
|
FrozenAmount int64 `json:"frozen_amount"`
|
|
TotalAssetAmount int64 `json:"total_asset_amount"`
|
|
TodayCommissionAmount int64 `json:"today_commission_amount"`
|
|
YesterdayCommissionAmount int64 `json:"yesterday_commission_amount"`
|
|
TodayClaimCount int `json:"today_claim_count"`
|
|
TodayCompletedCount int `json:"today_completed_count"`
|
|
}
|
|
|
|
type SubmitInput struct {
|
|
EvidenceText string `json:"evidence_text"`
|
|
EvidenceAttachmentIDs []int64 `json:"evidence_attachment_ids"`
|
|
}
|
|
|
|
type SubmitResult struct {
|
|
SubmissionNo string `json:"submission_no"`
|
|
Status string `json:"status"`
|
|
SubmittedAt string `json:"submitted_at"`
|
|
OrderStatus string `json:"order_status"`
|
|
}
|
|
|
|
type taskPolicy struct {
|
|
Enabled bool
|
|
MinimumAmount int64
|
|
Start string
|
|
End string
|
|
Deadline time.Duration
|
|
}
|
|
|
|
func New(repository Repository) *Service {
|
|
return &Service{repository: repository, now: time.Now}
|
|
}
|
|
|
|
func (s *Service) Halls(ctx context.Context, tenantID, memberID int64) ([]Hall, error) {
|
|
rows, err := s.repository.TaskHalls(ctx, tenantID, memberID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items := make([]Hall, 0, len(rows))
|
|
for _, row := range rows {
|
|
items = append(items, Hall{ID: row.ID, Code: row.Code, Name: row.Name, IconURL: row.IconURL,
|
|
Summary: row.Summary, CommissionRate: row.CommissionRate, RateUnit: row.RateUnit, ClientRoute: row.ClientRoute})
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
func (s *Service) Eligibility(ctx context.Context, tenantID, memberID, hallID int64) (*Eligibility, error) {
|
|
if hallID <= 0 {
|
|
return nil, ErrHallIDInvalid
|
|
}
|
|
policy, err := s.policy(ctx, tenantID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
row, err := s.repository.TaskEligibility(ctx, tenantID, memberID, hallID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result := &Eligibility{Eligible: true, AvailableAmount: row.AvailableAmount, MinimumAmount: policy.MinimumAmount,
|
|
TodayCount: row.TodayCount, DailyLimit: row.DailyLimit, PendingOrderID: row.PendingOrderID,
|
|
BusinessStartAt: policy.Start + "+08:00", BusinessEndAt: policy.End + "+08:00"}
|
|
switch {
|
|
case !policy.Enabled:
|
|
result.reject("TASK_DISABLED", "任务功能未开启")
|
|
case !withinBusinessHours(s.now().In(shanghai), policy.Start, policy.End):
|
|
result.reject("OUTSIDE_BUSINESS_HOURS", "当前不在营业时间")
|
|
case row.MemberStatus != 1:
|
|
result.reject("ACCOUNT_RESTRICTED", "账户状态受限")
|
|
case !row.HallEligible:
|
|
result.reject("LEVEL_NOT_ELIGIBLE", "当前等级不能进入该任务大厅")
|
|
case row.WalletStatus != 1:
|
|
result.reject("ACCOUNT_RESTRICTED", "账户状态受限")
|
|
case row.AvailableAmount < policy.MinimumAmount:
|
|
result.reject("INSUFFICIENT_BALANCE", "可用余额不足")
|
|
case row.DailyLimit > 0 && row.TodayCount >= row.DailyLimit:
|
|
result.reject("DAILY_LIMIT_REACHED", "已达当日领取上限")
|
|
case row.PendingOrderID > 0:
|
|
result.reject("PENDING_ORDER_EXISTS", "存在未完成订单")
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (e *Eligibility) reject(reason, message string) {
|
|
e.Eligible = false
|
|
e.Reason = reason
|
|
e.Message = message
|
|
}
|
|
|
|
func (s *Service) Claim(ctx context.Context, tenantID, memberID, hallID int64) (*Order, error) {
|
|
if hallID <= 0 {
|
|
return nil, ErrHallIDInvalid
|
|
}
|
|
policy, err := s.policy(ctx, tenantID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !policy.Enabled {
|
|
return nil, ErrTaskDisabled
|
|
}
|
|
if !withinBusinessHours(s.now().In(shanghai), policy.Start, policy.End) {
|
|
return nil, ErrOutsideBusinessHour
|
|
}
|
|
now := s.now().UTC()
|
|
row, err := s.repository.ClaimTask(ctx, store.ClaimTaskInput{TenantID: tenantID, MemberID: memberID,
|
|
HallID: hallID, MinimumAmount: policy.MinimumAmount, DeadlineAt: now.Add(policy.Deadline), OrderNo: newNumber("TO", now)})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result := orderFromStore(*row, false)
|
|
return &result, nil
|
|
}
|
|
|
|
func (s *Service) Orders(ctx context.Context, tenantID, memberID int64, page, pageSize int, status string) (*OrderPage, error) {
|
|
if page == 0 {
|
|
page = 1
|
|
}
|
|
if pageSize == 0 {
|
|
pageSize = defaultPageSize
|
|
}
|
|
if page < 1 {
|
|
return nil, ErrPageInvalid
|
|
}
|
|
if pageSize < 1 || pageSize > maxPageSize {
|
|
return nil, ErrPageSizeInvalid
|
|
}
|
|
statusCode, ok := orderStatusCode(status)
|
|
if !ok {
|
|
return nil, ErrStatusInvalid
|
|
}
|
|
rows, err := s.repository.TaskOrders(ctx, tenantID, memberID, statusCode, (page-1)*pageSize, pageSize)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items := make([]Order, 0, len(rows.Items))
|
|
for _, row := range rows.Items {
|
|
items = append(items, orderFromStore(row, false))
|
|
}
|
|
return &OrderPage{Items: items, Page: page, PageSize: pageSize, Total: rows.Total}, nil
|
|
}
|
|
|
|
func (s *Service) Order(ctx context.Context, tenantID, memberID, orderID int64) (*Order, error) {
|
|
if orderID <= 0 {
|
|
return nil, ErrOrderIDInvalid
|
|
}
|
|
row, err := s.repository.TaskOrder(ctx, tenantID, memberID, orderID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ledgers, err := s.repository.OrderLedgers(ctx, tenantID, memberID, []string{row.OrderNo, row.SubmissionNo})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result := orderFromStore(*row, true)
|
|
result.Ledgers = ledgers
|
|
return &result, nil
|
|
}
|
|
|
|
func (s *Service) Stats(ctx context.Context, tenantID, memberID int64) (*Stats, error) {
|
|
row, err := s.repository.TaskStats(ctx, tenantID, memberID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &Stats{AvailableAmount: row.AvailableAmount, FrozenAmount: row.FrozenAmount,
|
|
TotalAssetAmount: row.AvailableAmount + row.FrozenAmount, TodayCommissionAmount: row.TodayCommissionAmount,
|
|
YesterdayCommissionAmount: row.YesterdayCommissionAmount, TodayClaimCount: row.TodayClaimCount,
|
|
TodayCompletedCount: row.TodayCompletedCount}, nil
|
|
}
|
|
|
|
func (s *Service) Submit(ctx context.Context, tenantID, memberID, orderID int64, input SubmitInput) (*SubmitResult, error) {
|
|
if orderID <= 0 {
|
|
return nil, ErrOrderIDInvalid
|
|
}
|
|
input.EvidenceText = strings.TrimSpace(input.EvidenceText)
|
|
if len([]rune(input.EvidenceText)) > 2000 || len(input.EvidenceAttachmentIDs) > 9 ||
|
|
(input.EvidenceText == "" && len(input.EvidenceAttachmentIDs) == 0) || !validUniqueIDs(input.EvidenceAttachmentIDs) {
|
|
return nil, ErrEvidenceInvalid
|
|
}
|
|
now := s.now().UTC()
|
|
row, err := s.repository.SubmitTask(ctx, store.TaskSubmissionInput{TenantID: tenantID, MemberID: memberID,
|
|
OrderID: orderID, SubmissionNo: newNumber("TS", now), EvidenceText: input.EvidenceText,
|
|
AttachmentIDs: input.EvidenceAttachmentIDs})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &SubmitResult{SubmissionNo: row.SubmissionNo, Status: "reviewing",
|
|
SubmittedAt: row.SubmittedAt.Format(time.RFC3339), OrderStatus: orderStatusName(row.OrderStatus)}, nil
|
|
}
|
|
|
|
func (s *Service) policy(ctx context.Context, tenantID int64) (taskPolicy, error) {
|
|
raw, err := s.repository.CurrentSiteConfig(ctx, tenantID)
|
|
if err != nil {
|
|
return taskPolicy{}, err
|
|
}
|
|
var config map[string]any
|
|
if json.Unmarshal(raw, &config) != nil {
|
|
config = map[string]any{}
|
|
}
|
|
minutes := intValue(findValue(config, "task_deadline_minutes", "order_deadline_minutes", "taskOrderDeadlineMinutes"))
|
|
if minutes <= 0 || minutes > 24*60 {
|
|
minutes = defaultDeadlineMinute
|
|
}
|
|
start := clockValue(findValue(config, "task_business_start_at", "business_start_at", "taskBusinessStartAt"), "00:00:00")
|
|
end := clockValue(findValue(config, "task_business_end_at", "business_end_at", "taskBusinessEndAt"), "23:59:59")
|
|
return taskPolicy{Enabled: boolValue(findValue(config, "task_enabled", "taskEnabled", "TASK")),
|
|
MinimumAmount: max64(0, int64Value(findValue(config, "task_minimum_amount", "minimum_balance_amount", "taskMinimumAmount"))),
|
|
Start: start, End: end, Deadline: time.Duration(minutes) * time.Minute}, nil
|
|
}
|
|
|
|
func orderFromStore(row store.TaskOrder, detail bool) Order {
|
|
status := orderStatusName(row.OrderStatus)
|
|
result := Order{ID: row.ID, OrderNo: row.OrderNo, TaskID: row.TaskID,
|
|
Product: Product{ID: row.ProductID, Name: row.ProductName, CoverURL: row.CoverURL}, Quantity: row.Quantity,
|
|
UnitPriceAmount: row.UnitPriceAmount, TotalAmount: row.TotalAmount, CommissionAmount: row.CommissionAmount,
|
|
Currency: row.Currency, Status: status, StatusText: orderStatusText(row.OrderStatus), Message: row.Message,
|
|
DeadlineAt: row.DeadlineAt, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}
|
|
if !detail {
|
|
return result
|
|
}
|
|
result.Task = &TaskDetail{Title: row.TaskTitle, Description: row.TaskDescription, DetailHTML: row.TaskDetailHTML}
|
|
result.RuleSnapshot = row.RuleSnapshot
|
|
result.Timeline = orderTimeline(row)
|
|
result.AllowedActions = allowedActions(row)
|
|
if row.SubmissionNo != "" {
|
|
ids := make([]int64, 0)
|
|
_ = json.Unmarshal(row.EvidenceIDs, &ids)
|
|
result.Submission = &Submission{SubmissionNo: row.SubmissionNo, Status: submissionStatusName(row.SubmissionStatus),
|
|
StatusText: submissionStatusText(row.SubmissionStatus), EvidenceText: row.EvidenceText,
|
|
EvidenceAttachmentIDs: ids, ReviewReasonCode: row.ReviewReasonCode, ReviewReason: row.ReviewReason,
|
|
AllowResubmit: row.AllowResubmit == 1, SubmittedAt: row.SubmittedAt, ReviewedAt: row.ReviewedAt, SettledAt: row.SettledAt}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func orderTimeline(row store.TaskOrder) []TimelineEntry {
|
|
items := []TimelineEntry{{Status: "created", Text: "订单已创建", At: row.CreatedAt}}
|
|
if row.MatchedAt != "" {
|
|
items = append(items, TimelineEntry{Status: "matched", Text: "任务匹配成功", At: row.MatchedAt})
|
|
}
|
|
if row.SubmittedAt != "" {
|
|
items = append(items, TimelineEntry{Status: "submitted", Text: "任务凭证已提交", At: row.SubmittedAt})
|
|
}
|
|
if row.ReviewedAt != "" {
|
|
items = append(items, TimelineEntry{Status: submissionStatusName(row.SubmissionStatus), Text: submissionStatusText(row.SubmissionStatus), At: row.ReviewedAt})
|
|
}
|
|
if row.SettledAt != "" {
|
|
items = append(items, TimelineEntry{Status: "settled", Text: "佣金已结算", At: row.SettledAt})
|
|
}
|
|
if row.CompletedAt != "" {
|
|
items = append(items, TimelineEntry{Status: "completed", Text: "订单已完成", At: row.CompletedAt})
|
|
}
|
|
return items
|
|
}
|
|
|
|
func allowedActions(row store.TaskOrder) []string {
|
|
switch row.OrderStatus {
|
|
case 2:
|
|
return []string{"submit", "contact_support"}
|
|
case 3, 5, 6, 8:
|
|
return []string{"contact_support"}
|
|
default:
|
|
return []string{}
|
|
}
|
|
}
|
|
|
|
func orderStatusCode(value string) (int, bool) {
|
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
|
case "", "all":
|
|
return 0, true
|
|
case "matching":
|
|
return 1, true
|
|
case "pending":
|
|
return 2, true
|
|
case "processing":
|
|
return 3, true
|
|
case "completed":
|
|
return 4, true
|
|
case "expired":
|
|
return 5, true
|
|
case "frozen":
|
|
return 6, true
|
|
case "cancelled":
|
|
return 7, true
|
|
case "failed":
|
|
return 8, true
|
|
default:
|
|
return 0, false
|
|
}
|
|
}
|
|
|
|
func orderStatusName(status int) string {
|
|
names := map[int]string{1: "matching", 2: "pending", 3: "processing", 4: "completed", 5: "expired", 6: "frozen", 7: "cancelled", 8: "failed"}
|
|
if value, ok := names[status]; ok {
|
|
return value
|
|
}
|
|
return "unknown"
|
|
}
|
|
|
|
func orderStatusText(status int) string {
|
|
names := map[int]string{1: "匹配中", 2: "待处理", 3: "审核中", 4: "已完成", 5: "已超时", 6: "已冻结", 7: "已取消", 8: "失败"}
|
|
if value, ok := names[status]; ok {
|
|
return value
|
|
}
|
|
return "未知状态"
|
|
}
|
|
|
|
func submissionStatusName(status int) string {
|
|
names := map[int]string{1: "reviewing", 2: "approved", 3: "rejected", 4: "settled", 5: "reversed"}
|
|
return names[status]
|
|
}
|
|
|
|
func submissionStatusText(status int) string {
|
|
names := map[int]string{1: "待审核", 2: "已通过", 3: "已驳回", 4: "已结算", 5: "已冲正"}
|
|
return names[status]
|
|
}
|
|
|
|
func withinBusinessHours(now time.Time, start, end string) bool {
|
|
current := now.Hour()*3600 + now.Minute()*60 + now.Second()
|
|
startSecond := parseClock(start)
|
|
endSecond := parseClock(end)
|
|
if startSecond <= endSecond {
|
|
return current >= startSecond && current <= endSecond
|
|
}
|
|
return current >= startSecond || current <= endSecond
|
|
}
|
|
|
|
func parseClock(value string) int {
|
|
parsed, err := time.Parse("15:04:05", value)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return parsed.Hour()*3600 + parsed.Minute()*60 + parsed.Second()
|
|
}
|
|
|
|
func clockValue(value any, fallback string) string {
|
|
raw, ok := value.(string)
|
|
if !ok {
|
|
return fallback
|
|
}
|
|
raw = strings.TrimSpace(raw)
|
|
for _, layout := range []string{"15:04:05", "15:04"} {
|
|
if parsed, err := time.Parse(layout, raw); err == nil {
|
|
return parsed.Format("15:04:05")
|
|
}
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func findValue(config map[string]any, keys ...string) any {
|
|
for _, key := range keys {
|
|
for candidate, value := range config {
|
|
if strings.EqualFold(candidate, key) {
|
|
return value
|
|
}
|
|
}
|
|
}
|
|
for _, value := range config {
|
|
if nested, ok := value.(map[string]any); ok {
|
|
if found := findValue(nested, keys...); found != nil {
|
|
return found
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func boolValue(value any) bool {
|
|
switch typed := value.(type) {
|
|
case bool:
|
|
return typed
|
|
case float64:
|
|
return typed == 1
|
|
case string:
|
|
return typed == "1" || strings.EqualFold(typed, "true") || strings.EqualFold(typed, "on")
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func intValue(value any) int {
|
|
return int(int64Value(value))
|
|
}
|
|
|
|
func int64Value(value any) int64 {
|
|
switch typed := value.(type) {
|
|
case float64:
|
|
return int64(typed)
|
|
case json.Number:
|
|
parsed, _ := typed.Int64()
|
|
return parsed
|
|
case string:
|
|
parsed, _ := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
|
|
return parsed
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func max64(left, right int64) int64 {
|
|
if left > right {
|
|
return left
|
|
}
|
|
return right
|
|
}
|
|
|
|
func validUniqueIDs(ids []int64) bool {
|
|
seen := make(map[int64]struct{}, len(ids))
|
|
for _, id := range ids {
|
|
if id <= 0 {
|
|
return false
|
|
}
|
|
if _, exists := seen[id]; exists {
|
|
return false
|
|
}
|
|
seen[id] = struct{}{}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func newNumber(prefix string, now time.Time) string {
|
|
bytes := make([]byte, 4)
|
|
if _, err := rand.Read(bytes); err != nil {
|
|
return fmt.Sprintf("%s%s%06d", prefix, now.Format("20060102150405"), now.UnixNano()%1_000_000)
|
|
}
|
|
return prefix + now.Format("20060102150405") + strings.ToUpper(hex.EncodeToString(bytes))
|
|
}
|