feat(role): 增加用户绑定角色信息查询与作战表展示

- 逆向并实现虎牙活动绑角色接口 webActUI.checkUserBindGameAccount,支持精准解析游戏角色名、区服和平台
- 增加本地持久化 roles.json 存储,启动即回显角色信息无需重复请求
- 前端账号作战表与 CK 管理列表整合角色展示([微信-安卓] 角色名),直观区分账号归属
- 工具栏增加「查询角色」按钮,并以兑换记录中的历史账号作为双重兜底
- 升级版本至 v0.5.5 并成功交叉编译 Windows 产物
This commit is contained in:
yml2213
2026-10-19 12:09:24 +08:00
parent c0be784be9
commit 7dadcd1644
19 changed files with 1032 additions and 31 deletions
+3
View File
@@ -52,6 +52,9 @@ type App struct {
// scoreBaselines 记录每个账号抢购前的积分基线,抢购后用于刚性扣分对账。
scoreBaselines *score.BaselineStore
// roleStore 记录各账号绑定的角色信息,并在本地持久化保存。
roleStore *RoleStore
runMu sync.Mutex
monitorCancel context.CancelFunc
+288
View File
@@ -0,0 +1,288 @@
package gui
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"huya-go/internal/huya"
"huya-go/internal/sniper"
)
const RoleStoreFileName = "roles.json"
// RoleInfo 描述一个账号绑定的角色详情,供本地持久化及前端展示。
type RoleInfo struct {
UID int64 `json:"uid"`
RoleName string `json:"role_name"`
AreaName string `json:"area_name"`
PlatName string `json:"plat_name"`
DisplayName string `json:"display_name"`
GameName string `json:"game_name"`
IsBound bool `json:"is_bound"`
UpdatedAt int64 `json:"updated_at"`
}
// RoleResult 是批量查询角色时的单账号结果。
type RoleResult struct {
UID int64 `json:"uid"`
RoleName string `json:"role_name"`
AreaName string `json:"area_name"`
PlatName string `json:"plat_name"`
DisplayName string `json:"display_name"`
GameName string `json:"game_name"`
IsBound bool `json:"is_bound"`
Resp *huya.CheckUserBindGameAccountResp `json:"resp,omitempty"`
Error string `json:"error,omitempty"`
}
// RoleStore 在各实例数据目录下持久化保存各账号的角色信息。
type RoleStore struct {
path string
mu sync.RWMutex
roles map[int64]RoleInfo
}
func NewRoleStore(path string) *RoleStore {
return &RoleStore{
path: path,
roles: make(map[int64]RoleInfo),
}
}
func (s *RoleStore) Load() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.path == "" {
return nil
}
data, err := os.ReadFile(s.path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
var raw map[string]RoleInfo
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
s.roles = make(map[int64]RoleInfo, len(raw))
for k, v := range raw {
if uid, err := strconv.ParseInt(k, 10, 64); err == nil {
s.roles[uid] = v
}
}
return nil
}
func (s *RoleStore) Save() error {
s.mu.RLock()
raw := make(map[string]RoleInfo, len(s.roles))
for k, v := range s.roles {
raw[strconv.FormatInt(k, 10)] = v
}
s.mu.RUnlock()
if s.path == "" {
return nil
}
data, err := json.MarshalIndent(raw, "", " ")
if err != nil {
return err
}
dir := filepath.Dir(s.path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
tmpFile := fmt.Sprintf("%s.tmp.%d", s.path, time.Now().UnixNano())
if err := os.WriteFile(tmpFile, data, 0o644); err != nil {
return err
}
return os.Rename(tmpFile, s.path)
}
func (s *RoleStore) Get(uid int64) (RoleInfo, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
info, ok := s.roles[uid]
return info, ok
}
func (s *RoleStore) Set(info RoleInfo) {
s.mu.Lock()
if info.UpdatedAt == 0 {
info.UpdatedAt = time.Now().Unix()
}
s.roles[info.UID] = info
s.mu.Unlock()
}
func (s *RoleStore) SetMany(list []RoleInfo) {
s.mu.Lock()
now := time.Now().Unix()
for _, info := range list {
if info.UpdatedAt == 0 {
info.UpdatedAt = now
}
s.roles[info.UID] = info
}
s.mu.Unlock()
}
func (s *RoleStore) GetAll() map[string]RoleInfo {
s.mu.RLock()
defer s.mu.RUnlock()
out := make(map[string]RoleInfo, len(s.roles))
for k, v := range s.roles {
out[strconv.FormatInt(k, 10)] = v
}
return out
}
// GetCachedRoles 获取已持久化的角色缓存字典,供前端初始化加载。
func (a *App) GetCachedRoles() (map[string]RoleInfo, error) {
a.ensureRoleStore()
if a.roleStore == nil {
return map[string]RoleInfo{}, nil
}
return a.roleStore.GetAll(), nil
}
func (a *App) ensureRoleStore() {
if a.roleStore == nil && a.dataDir != "" {
a.roleStore = NewRoleStore(filepath.Join(a.dataDir, RoleStoreFileName))
_ = a.roleStore.Load()
}
}
// QueryAccountRoles 并发查询当前账号绑定的角色信息。
// 优先调用虎牙 webActUI.checkUserBindGameAccount;
// QueryRoles 并发查询当前账号绑定的角色信息。
// 优先调用虎牙 webActUI.checkUserBindGameAccount;
// 若未绑定角色但历史兑换记录中存在 UserPrizeInfo.Account,以此作为兜底展示;
// 查询成功后自动持久化存盘到 roles.json。
func (a *App) QueryRoles(cfg Config) ([]RoleResult, error) {
return a.QueryAccountRoles(cfg)
}
func (a *App) QueryAccountRoles(cfg Config) ([]RoleResult, error) {
cfg = normalizeConfig(cfg)
accounts, err := parseAccounts(cfg)
if err != nil {
return nil, err
}
if len(accounts) == 0 {
return nil, errors.New("没有有效的账号凭据")
}
client, closeClient, err := a.newClient(cfg)
if err != nil {
return nil, err
}
defer closeClient()
a.ensureRoleStore()
ctx, cancel := requestContext(cfg.TimeoutSec)
defer cancel()
concurrency := cfg.Concurrency
if concurrency <= 0 {
concurrency = 3
}
if concurrency > len(accounts) {
concurrency = len(accounts)
}
results := make([]RoleResult, len(accounts))
sem := make(chan struct{}, concurrency)
var wg sync.WaitGroup
a.addLog("info", "开始查询绑定角色: accounts=%d 并发=%d", len(accounts), concurrency)
for i, acct := range accounts {
wg.Add(1)
go func(idx int, targetAcct sniper.Account) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
res := RoleResult{UID: targetAcct.UID}
// 1. 发起 checkUserBindGameAccount 查询
// bActID: 和平精英腾讯互联活动 ID,抓包确认为 9271
resp, callErr := client.CheckUserBindGameAccount(ctx, targetAcct.UID, targetAcct.Cookie, 9271)
if callErr != nil {
res.Error = callErr.Error()
} else if resp != nil {
res.Resp = resp
res.DisplayName = resp.RoleDisplayName()
res.RoleName = resp.AccountData.GameRole.RoleName
res.AreaName = resp.AccountData.GameRole.AreaName
res.PlatName = resp.AccountData.GameRole.PlatName
res.GameName = resp.GameName
res.IsBound = resp.AccountData.IsBindRole == 1
}
// 2. 兜底策略:如果主接口未查到角色名,尝试用 getUserPrizeRecords 里的历史记录兜底
if res.RoleName == "" && cfg.SID > 0 {
if recResp, recErr := client.GetUserPrizeRecords(ctx, targetAcct.UID, targetAcct.Cookie, cfg.SID); recErr == nil && recResp != nil {
for _, rec := range recResp.Records {
if strings.TrimSpace(rec.Account) != "" {
res.RoleName = strings.TrimSpace(rec.Account)
res.DisplayName = res.RoleName + " (历史)"
res.IsBound = true
break
}
}
}
}
results[idx] = res
}(i, acct)
}
wg.Wait()
// 更新到 RoleStore 并持久化存盘
if a.roleStore != nil {
toSave := make([]RoleInfo, 0, len(results))
for _, r := range results {
if r.DisplayName != "" || r.RoleName != "" {
toSave = append(toSave, RoleInfo{
UID: r.UID,
RoleName: r.RoleName,
AreaName: r.AreaName,
PlatName: r.PlatName,
DisplayName: r.DisplayName,
GameName: r.GameName,
IsBound: r.IsBound,
})
}
}
if len(toSave) > 0 {
a.roleStore.SetMany(toSave)
if saveErr := a.roleStore.Save(); saveErr != nil {
a.addLog("warn", "保存 roles.json 失败: %v", saveErr)
}
}
}
boundCount := 0
for _, r := range results {
if r.IsBound && r.DisplayName != "" {
boundCount++
}
}
a.addLog("info", "查询绑定角色完成: accounts=%d 已绑定=%d", len(accounts), boundCount)
return results, nil
}
+43
View File
@@ -0,0 +1,43 @@
package gui
import (
"path/filepath"
"testing"
)
func TestRoleStoreCRUDAndSave(t *testing.T) {
tmpDir := t.TempDir()
path := filepath.Join(tmpDir, "roles.json")
store := NewRoleStore(path)
store.Set(RoleInfo{
UID: 1199668605121,
RoleName: "白菜被拱了呀",
AreaName: "微信",
PlatName: "安卓",
DisplayName: "白菜被拱了呀 [微信-安卓]",
GameName: "和平精英",
IsBound: true,
})
if err := store.Save(); err != nil {
t.Fatalf("save: %v", err)
}
// 重新 Load
store2 := NewRoleStore(path)
if err := store2.Load(); err != nil {
t.Fatalf("load: %v", err)
}
info, ok := store2.Get(1199668605121)
if !ok {
t.Fatalf("expected role for 1199668605121")
}
if info.RoleName != "白菜被拱了呀" {
t.Errorf("expected roleName 白菜被拱了呀, got %s", info.RoleName)
}
if info.DisplayName != "白菜被拱了呀 [微信-安卓]" {
t.Errorf("expected displayName 白菜被拱了呀 [微信-安卓], got %s", info.DisplayName)
}
}
+214
View File
@@ -19,6 +19,7 @@ const (
FuncPrizeList = "getActPrizeList"
FuncPrizeRecords = "getUserPrizeRecords"
FuncExchangePrize = "scoreExchangePrize"
FuncCheckUserBindGameAccount = "checkUserBindGameAccount"
)
type ActivityUserID struct {
@@ -842,3 +843,216 @@ func exchangeResponseMessage(resp *ScoreExchangePrizeResp) string {
}
return ""
}
type CheckUserBindGameAccountReq struct {
User ActivityUserID
GID int32
OuterActID string
IsInnerBind int32
Scene string
BActID int32
IsUseOuterActID int32
}
func (q *CheckUserBindGameAccountReq) WriteTo(w *taf.Writer) error {
if err := w.WriteStruct(0, &q.User); err != nil {
return err
}
if err := w.WriteInt32(1, int64(q.GID)); err != nil {
return err
}
if err := w.WriteString(2, q.OuterActID); err != nil {
return err
}
if err := w.WriteInt32(3, int64(q.IsInnerBind)); err != nil {
return err
}
if err := w.WriteString(4, q.Scene); err != nil {
return err
}
if err := w.WriteInt32(5, int64(q.BActID)); err != nil {
return err
}
return w.WriteInt32(6, int64(q.IsUseOuterActID))
}
func (q *CheckUserBindGameAccountReq) ReadFrom(r *taf.Reader) error {
_, err := r.ReadStruct(0, false, &q.User)
if err != nil {
return err
}
if q.GID, err = r.ReadInt32(1, false, q.GID); err != nil {
return err
}
if q.OuterActID, err = r.ReadString(2, false, q.OuterActID); err != nil {
return err
}
if q.IsInnerBind, err = r.ReadInt32(3, false, q.IsInnerBind); err != nil {
return err
}
if q.Scene, err = r.ReadString(4, false, q.Scene); err != nil {
return err
}
if q.BActID, err = r.ReadInt32(5, false, q.BActID); err != nil {
return err
}
q.IsUseOuterActID, err = r.ReadInt32(6, false, q.IsUseOuterActID)
return err
}
type GameRole struct {
RoleID string `json:"role_id"`
RoleName string `json:"role_name"`
AreaName string `json:"area_name"`
PlatName string `json:"plat_name"`
PartitionName string `json:"partition_name"`
GameSerial string `json:"game_serial"`
}
func (g *GameRole) WriteTo(w *taf.Writer) error { return nil }
func (g *GameRole) ReadFrom(r *taf.Reader) error {
var err error
if g.RoleID, err = r.ReadString(0, false, g.RoleID); err != nil {
return err
}
if g.RoleName, err = r.ReadString(1, false, g.RoleName); err != nil {
return err
}
if g.AreaName, err = r.ReadString(2, false, g.AreaName); err != nil {
return err
}
if g.PlatName, err = r.ReadString(3, false, g.PlatName); err != nil {
return err
}
if g.PartitionName, err = r.ReadString(4, false, g.PartitionName); err != nil {
return err
}
g.GameSerial, err = r.ReadString(5, false, g.GameSerial)
return err
}
type GameAccount struct {
OpenID string `json:"open_id"`
AppID string `json:"app_id"`
Type string `json:"type"`
FaceURL string `json:"face_url"`
Nick string `json:"nick"`
AccessToken string `json:"access_token"`
ExtendInfo string `json:"extend_info"`
}
func (g *GameAccount) WriteTo(w *taf.Writer) error { return nil }
func (g *GameAccount) ReadFrom(r *taf.Reader) error {
var err error
if g.OpenID, err = r.ReadString(0, false, g.OpenID); err != nil {
return err
}
if g.AppID, err = r.ReadString(1, false, g.AppID); err != nil {
return err
}
if g.Type, err = r.ReadString(2, false, g.Type); err != nil {
return err
}
if g.FaceURL, err = r.ReadString(3, false, g.FaceURL); err != nil {
return err
}
if g.Nick, err = r.ReadString(4, false, g.Nick); err != nil {
return err
}
if g.AccessToken, err = r.ReadString(5, false, g.AccessToken); err != nil {
return err
}
g.ExtendInfo, err = r.ReadString(6, false, g.ExtendInfo)
return err
}
type GameAccountData struct {
IsBindAccount int32 `json:"is_bind_account"`
IsBindRole int32 `json:"is_bind_role"`
IsNeedActCheck int32 `json:"is_need_act_check"`
GameAccount GameAccount `json:"game_account"`
GameRole GameRole `json:"game_role"`
ChangeBindTime int32 `json:"change_bind_time"`
}
func (d *GameAccountData) WriteTo(w *taf.Writer) error { return nil }
func (d *GameAccountData) ReadFrom(r *taf.Reader) error {
var err error
if d.IsBindAccount, err = r.ReadInt32(0, false, d.IsBindAccount); err != nil {
return err
}
if d.IsBindRole, err = r.ReadInt32(1, false, d.IsBindRole); err != nil {
return err
}
if d.IsNeedActCheck, err = r.ReadInt32(2, false, d.IsNeedActCheck); err != nil {
return err
}
if _, err = r.ReadStruct(3, false, &d.GameAccount); err != nil {
return err
}
if _, err = r.ReadStruct(4, false, &d.GameRole); err != nil {
return err
}
d.ChangeBindTime, err = r.ReadInt32(5, false, d.ChangeBindTime)
return err
}
type CheckUserBindGameAccountResp struct {
Status int32 `json:"status"`
Msg string `json:"msg"`
AccountData GameAccountData `json:"account_data"`
GameName string `json:"game_name"`
}
func (r *CheckUserBindGameAccountResp) WriteTo(w *taf.Writer) error { return nil }
func (r *CheckUserBindGameAccountResp) ReadFrom(rd *taf.Reader) error {
var err error
if r.Status, err = rd.ReadInt32(0, false, r.Status); err != nil {
return err
}
if r.Msg, err = rd.ReadString(1, false, r.Msg); err != nil {
return err
}
if _, err = rd.ReadStruct(2, false, &r.AccountData); err != nil {
return err
}
r.GameName, err = rd.ReadString(3, false, r.GameName)
return err
}
// RoleDisplayName 格式化角色展示名称,如 "白菜被拱了呀 [微信-安卓]",未绑定时返回空串或未绑定。
func (r *CheckUserBindGameAccountResp) RoleDisplayName() string {
if r == nil || r.Status != 200 {
return ""
}
role := r.AccountData.GameRole
if strings.TrimSpace(role.RoleName) == "" {
if r.AccountData.IsBindRole == 0 && r.AccountData.IsBindAccount == 0 {
return "未绑定角色"
}
if r.AccountData.GameAccount.Nick != "" {
return r.AccountData.GameAccount.Nick
}
return ""
}
var tagParts []string
if role.AreaName != "" {
tagParts = append(tagParts, role.AreaName)
}
if role.PlatName != "" {
plat := strings.TrimSuffix(strings.TrimSuffix(role.PlatName, "(android)"), "(iOS)")
plat = strings.TrimSpace(plat)
if plat != "" {
tagParts = append(tagParts, plat)
}
}
if len(tagParts) > 0 {
return fmt.Sprintf("%s [%s]", role.RoleName, strings.Join(tagParts, "-"))
}
return role.RoleName
}
+51
View File
@@ -0,0 +1,51 @@
package huya
import (
"encoding/hex"
"testing"
"huya-go/internal/wup"
)
var sampleBindRespHex = "000000be10032c3c4001560877656241637455496618636865636b5573657242696e6447616d654163636f756e747d0001008408000206001d0000010c0604745273701d0000700a0100c816002a0001100120013a0600160026027171360046085151e794a8e688b7560066000b4a0600160fe5bfa7e98381e5988ee5b7b4e8b1862604e6898b51360fe5ae89e58d9328616e64726f696429460056000b526a4fc5800b360ce5928ce5b9b3e7b2bee88bb1460050070b811388980ca80c"
func TestDecodeCheckUserBindGameAccountResp(t *testing.T) {
respBytes, err := hex.DecodeString(sampleBindRespHex)
if err != nil {
t.Fatalf("decode hex: %v", err)
}
var wResp wup.Response
if err := wResp.Decode(respBytes); err != nil {
t.Fatalf("wup decode: %v", err)
}
var resp CheckUserBindGameAccountResp
ok, err := wResp.ReadStruct("tRsp", &resp)
if err != nil {
t.Fatalf("readStruct tRsp: %v", err)
}
if !ok {
t.Fatalf("tRsp not found in response")
}
t.Logf("Status: %d, Msg: %s, GameName: %s", resp.Status, resp.Msg, resp.GameName)
t.Logf("RoleName: %s, AreaName: %s, PlatName: %s", resp.AccountData.GameRole.RoleName, resp.AccountData.GameRole.AreaName, resp.AccountData.GameRole.PlatName)
t.Logf("DisplayName: %s", resp.RoleDisplayName())
if resp.Status != 200 {
t.Errorf("expected status 200, got %d", resp.Status)
}
if resp.AccountData.GameRole.RoleName != "忧郁嘎巴豆" {
t.Errorf("expected roleName 忧郁嘎巴豆, got %s", resp.AccountData.GameRole.RoleName)
}
if resp.AccountData.GameRole.AreaName != "手Q" {
t.Errorf("expected areaName 手Q, got %s", resp.AccountData.GameRole.AreaName)
}
if resp.AccountData.GameRole.PlatName != "安卓(android)" {
t.Errorf("expected platName 安卓(android), got %s", resp.AccountData.GameRole.PlatName)
}
if resp.RoleDisplayName() != "忧郁嘎巴豆 [手Q-安卓]" {
t.Errorf("expected roleDisplayName 忧郁嘎巴豆 [手Q-安卓], got %s", resp.RoleDisplayName())
}
}
+1
View File
@@ -13,6 +13,7 @@ type ActivityClient interface {
QueryUserScore(ctx context.Context, uid int64, cookie string, sid int32) (*GetUserScoreResp, error)
GetUserPrizeRecords(ctx context.Context, uid int64, cookie string, sid int32) (*GetUserPrizeRecordsResp, error)
ScoreExchangePrize(ctx context.Context, uid int64, cookie string, sid, pid int32) (*ScoreExchangePrizeResp, error)
CheckUserBindGameAccount(ctx context.Context, uid int64, cookie string, bActID int32) (*CheckUserBindGameAccountResp, error)
}
// NewActivityClient 按传输层创建活动客户端,供 CLI 和 GUI 共用。
+18
View File
@@ -276,3 +276,21 @@ func (c *HTTPClient) ScoreExchangePrize(ctx context.Context, uid int64, cookie s
}
return &resp, err
}
func (c *HTTPClient) CheckUserBindGameAccount(ctx context.Context, uid int64, cookie string, bActID int32) (*CheckUserBindGameAccountResp, error) {
if bActID <= 0 {
bActID = 9271
}
req := &CheckUserBindGameAccountReq{
User: c.buildUser(uid, cookie, ActivityWebUA),
BActID: bActID,
IsUseOuterActID: 1,
}
var resp CheckUserBindGameAccountResp
_, err := c.CallRPC(ctx, ServantWebActUI, FuncCheckUserBindGameAccount, req, &resp, CallOptions{UID: uid, Cookie: cookie, RequestID: 1})
if err == nil {
c.logf("[HTTP] 绑定角色已解码 uid=%d status=%d role=%s game=%s msg=%s", uid, resp.Status, resp.RoleDisplayName(), resp.GameName, resp.Msg)
}
return &resp, err
}
+21
View File
@@ -793,6 +793,27 @@ func (c *WSSClient) ScoreExchangePrize(ctx context.Context, uid int64, cookie st
return &resp, err
}
func (c *WSSClient) CheckUserBindGameAccount(ctx context.Context, uid int64, cookie string, bActID int32) (*CheckUserBindGameAccountResp, error) {
session, err := c.session(ctx, uid, cookie)
if err != nil {
return nil, err
}
if bActID <= 0 {
bActID = 9271
}
req := &CheckUserBindGameAccountReq{
User: c.buildUser(uid, cookie),
BActID: bActID,
IsUseOuterActID: 1,
}
var resp CheckUserBindGameAccountResp
_, err = session.callRPC(ctx, ServantWebActUI, FuncCheckUserBindGameAccount, req, &resp)
if err == nil {
c.logf("[WSS] 绑定角色已解码 uid=%d status=%d role=%s game=%s msg=%s", uid, resp.Status, resp.RoleDisplayName(), resp.GameName, resp.Msg)
}
return &resp, err
}
func NormalizeWSSCookie(cookie string) string {
cookie = NormalizeCookie(cookie)
pairs := CookiePairs(cookie)
+4
View File
@@ -32,6 +32,10 @@ func (m *mockScoreClient) GetUserPrizeRecords(ctx context.Context, uid int64, co
func (m *mockScoreClient) ScoreExchangePrize(ctx context.Context, uid int64, cookie string, sid, pid int32) (*huya.ScoreExchangePrizeResp, error) {
return nil, nil
}
func (m *mockScoreClient) CheckUserBindGameAccount(ctx context.Context, uid int64, cookie string, bActID int32) (*huya.CheckUserBindGameAccountResp, error) {
return nil, nil
}
func TestBaselineStoreCRUDAndFlush(t *testing.T) {
tmpDir := t.TempDir()
+18
View File
@@ -60,6 +60,24 @@ func (f *fakeActivityClient) ScoreExchangePrize(ctx context.Context, uid int64,
return &huya.ScoreExchangePrizeResp{Status: 200, Msg: "请求成功"}, nil
}
func (f *fakeActivityClient) CheckUserBindGameAccount(ctx context.Context, uid int64, cookie string, bActID int32) (*huya.CheckUserBindGameAccountResp, error) {
return &huya.CheckUserBindGameAccountResp{
Status: 200,
Msg: "请求成功",
AccountData: huya.GameAccountData{
IsBindAccount: 1,
IsBindRole: 1,
GameRole: huya.GameRole{
RoleName: "测试角色",
AreaName: "微信",
PlatName: "安卓",
},
},
GameName: "和平精英",
}, nil
}
func (f *fakeActivityClient) PrewarmActivity(ctx context.Context, uid int64, cookie string, sid int32) error {
f.mu.Lock()
f.prewarmCount++
+1 -1
View File
@@ -6,7 +6,7 @@ package version
// 构建时可用 ldflags 覆盖(构建脚本就是这么做的):
//
// go build -ldflags "-X huya-go/internal/version.Version=0.5.4"
var Version = "0.5.4"
var Version = "0.5.5"
// Display 返回带 v 前缀的展示版本号,例如 v0.5.4。
func Display() string {