美加墨世界杯在即,我手撸了一个竞猜系统

61 阅读10分钟

前言

2026年美加墨世界杯(The 23rd FIFA World Cup)将于2026年6月11日至7月19日举行。​这是世界杯历史上首次由美国、加拿大和墨西哥三国联合举办,​也是首届扩军至48支球队参赛的空前规模赛事。​比赛共计104场,包括72场小组赛和32场淘汰赛。​赛事的增加,这对足球迷来说是一场饕餮盛宴。​小组赛不是必须分出胜负,有主胜、平局和客胜3种可能性,​而淘汰赛只有主胜和客胜2种可能性。​如果在90分钟内未分出胜负,则会进行30分钟的加时赛,​如果还未分出胜负,​则直接进行万众瞩目的点球大战来定最终的胜负。看比赛,当然少不了啤酒跟零食,​同时参与各大网络平台的竞猜活动也更增添了一分欢乐。这不,​我自己也写了一套赛事竞猜系统,发布到朵拉音乐平台,https​://pyger.com/doramusic,​下载地址我已经放到这里了,欢迎各大网友下载体验。另外,​对于爱好学习的小伙伴我也准备了一份福利,​那就是朵拉音乐的Android客户端的源代码,​我已经开源到Github上了,https://github.​com/dora4/DoraMusic,​有兴趣的同学可以自行研究。

效果演示

那么废话不多说,先看效果。

管理系统

管理系统前端使用layui写的。

截屏2026-05-24 23.33.25.png

截屏2026-05-24 23.48.24.png

截屏2026-05-24 23.58.18.png

截屏2026-05-24 23.58.46.png

截屏2026-05-24 23.59.48.png

Android客户端

Android客户端推荐“主题色曜石黑+深色模式”参与世界杯竞猜。

Screenshot_20260525_002653_site.doramusic.app.jpg Screenshot_20260525_001307_site.doramusic.app.jpg

Screenshot_20260525_001304_site.doramusic.app.jpg

Screenshot_20260525_000023_site.doramusic.app.jpg

Screenshot_20260524_235925_site.doramusic.app.jpg

Screenshot_20260524_235745_site.doramusic.app.jpg

Screenshot_20260524_235129_site.doramusic.app.jpg

Screenshot_20260524_235115_site.doramusic.app.jpg

1000000427.jpg

1000000428.jpg

1000000424.jpg

1000000426.jpg

1000000425.jpg

规则介绍

参与方式

支持账号登录和游客登录。用户可以以游客身份进入竞猜界面直接参与竞猜,也可以通过登录Dora Chat账号方式参与。如果需要更换设备,请选择账号登录。

积分

当前唯一积分获取渠道为播放本地音乐,​每累积1分钟获得10积分。

投注

单个竞猜,投注积分范围为1~100000,​站好队后只能追加该选项的积分投入,不可再投入其它选项,最多可累积到100000积分不可再投。

赔率

赔率 = 奖励积分 / 投入积分。

排行榜

排行榜分为胜率榜、盈亏榜和投注榜。为避免恶意占榜,​给予大家公平的上榜机会,胜率榜至少需要投注5场竞猜才会计入。​如果使用游客登录,则会默认使用游客ID上榜,如果使用账号登录,则截取ERC20钱包地址的部分内容进行显示。如果设置了用户昵称,则优先显示昵称。

竞猜

竞猜会在比赛开始时自动封盘,特殊情况管理员会手动封盘,​封盘后不可再进行投注。比赛结束后,管理员会对竞猜结果进行结算,​系统会自动根据用户的投注比例结合赔率进行奖励积分的计算。​最终需要用户自己到“我的竞猜”界面手动领取奖励积分。

淘汰赛积分的额外奖励规则

  • 【冠亚军决赛】猜中胜负额外奖励100000积分

  • 【三四名决赛】猜中胜负额外奖励90000积分

  • 【半决赛】猜中胜负额外奖励80000积分

  • 【1/4决赛】猜中胜负额外奖励70000积分

  • 【1/8决赛】猜中胜负额外奖励60000积分

  • 【1/16决赛】猜中胜负额外奖励50000积分

整体架构

后端:Java + SpringBoot + Mybatis + Redis + MySQL + SpringSecurity + Layui + DoraChat双认证系统

Android端:Kotlin + Databinding + ARouter + Dora全家桶

实现思路

后端

竞猜内容和选项内容略过,以下为竞猜核心逻辑。包括了投注、奖池分配、结算、汇总用户竞猜结果、竞猜奖励领取和竞猜排行榜。

新增投注
<insert id="insert"
        useGeneratedKeys="true"
        keyProperty="id">
    INSERT INTO dora_guessing_bet
    (
        user_id,
        guessing_id,
        item_id,
        score,
        reward_score,
        is_claimed,
        create_time
    )
    VALUES
    (
        #{userId},
        #{guessingId},
        #{itemId},
        #{score},
        0,
        0,
        NOW()
    )
</insert>
查询投注
查询用户投注
<select id="listByUserAndGuessing"
        resultMap="BaseResultMap">
    SELECT *
    FROM dora_guessing_bet
    WHERE user_id = #{userId}
      AND guessing_id = #{guessingId}
</select>
查询总投注
<select id="sumUserScoreByGuessing"
        resultType="java.lang.Long">
    SELECT COALESCE(SUM(score), 0)
    FROM dora_guessing_bet
    WHERE user_id = #{userId}
      AND guessing_id = #{guessingId}
</select>
查询奖池
查询竞猜总奖池
<select id="sumScoreByGuessingId"
         resultType="java.lang.Long">
    SELECT COALESCE(SUM(score), 0)
    FROM dora_guessing_bet
    WHERE guessing_id = #{guessingId}
</select>
查询获胜奖池
<select id="sumScoreByItemId"
        resultType="java.lang.Long">
    SELECT COALESCE(SUM(score), 0)
    FROM dora_guessing_bet
    WHERE item_id = #{itemId}
</select>
竞猜结算
结算奖励
<update id="updateReward">
    UPDATE dora_guessing_bet
    SET reward_score = #{reward}
    WHERE id = #{id}
</update>
结算时需要依赖
<select id="listByItemId" resultType="DoraGuessingBet">
    SELECT * FROM dora_guessing_bet
    WHERE item_id = #{itemId}
</select>
获取我的竞猜奖励列表
<select id="rewardList"
        resultMap="RewardResultMap">
    SELECT
        g.id AS guessingId,
        g.title AS title,
        SUM(b.score) AS totalScore,
        SUM(b.reward_score) AS totalRewardScore,
        CASE
            WHEN g.status = 2 THEN 1
            ELSE 0
        END AS opened,
        CASE
            WHEN g.status = 2 AND SUM(b.reward_score) > 0 THEN 1
            ELSE 0
        END AS win,
        MIN(b.is_claimed) AS claimed,
        CASE
            WHEN SUM(b.score) > 0
            THEN ROUND(SUM(b.reward_score) * 1.0 / SUM(b.score), 4)
            ELSE 0
        END AS odds
    FROM dora_guessing_bet b
    LEFT JOIN dora_guessing_info g
        ON g.id = b.guessing_id
    WHERE b.user_id = #{userId}
    GROUP BY b.guessing_id, g.id, g.title, g.status
    ORDER BY b.guessing_id DESC
</select>
领取奖励
标记奖励领取
<update id="claim">
    UPDATE dora_guessing_bet
    SET is_claimed = 1
    WHERE guessing_id = #{guessingId}
      AND user_id = #{userId}
      AND is_claimed = 0
</update>
归总计算单竞猜所有投注的奖励总和
<select id="sumClaimableReward"
        resultType="java.lang.Long">
    SELECT COALESCE(SUM(reward_score), 0)
    FROM dora_guessing_bet
    WHERE user_id = #{userId}
      AND guessing_id = #{guessingId}
      AND is_claimed = 0
</select>
排行榜
胜率榜

排出胜率榜前10名,投注>=5场才能上榜。

<select id="winRank"
        resultType="com.doramusic.pojo.resp.DoraGuessingRank">
    SELECT
        b.user_id AS userId,
        u.nickname AS nickname,
        COUNT(DISTINCT b.guessing_id) AS totalGuessing,
        COUNT(
            DISTINCT CASE
                WHEN b.item_id = r.result_item_id
                THEN b.guessing_id
            END
        ) AS winCount,
        CAST(
            ROUND(
                COUNT(
                    DISTINCT CASE
                        WHEN b.item_id = r.result_item_id
                        THEN b.guessing_id
                    END
                ) * 1.0
                /
                COUNT(DISTINCT b.guessing_id),
                4
            ) AS DECIMAL(10,4)
        ) AS winRate
    FROM dora_guessing_bet b
    JOIN dora_guessing_result r
        ON b.guessing_id = r.guessing_id
    LEFT JOIN dora_guessing_user u
        ON b.user_id = u.user_id
    GROUP BY b.user_id, u.nickname
    HAVING COUNT(DISTINCT b.guessing_id) >= 5
    ORDER BY winRate DESC
    LIMIT 10
</select>
盈亏榜

排出盈亏榜前10名。

<select id="profitRank"
        resultType="com.doramusic.pojo.resp.DoraGuessingRank">
    SELECT
        b.user_id AS userId,
        u.nickname AS nickname,
        SUM(b.score) AS totalBet,
        SUM(b.reward_score) AS totalReward,
        (SUM(b.reward_score) - SUM(b.score)) AS profit
    FROM dora_guessing_bet b
    LEFT JOIN dora_guessing_user u
        ON b.user_id = u.user_id
    GROUP BY b.user_id, u.nickname
    ORDER BY profit DESC
    LIMIT 10
</select>
投注榜

排出投注榜前10名。

<select id="betRank"
        resultType="com.doramusic.pojo.resp.DoraGuessingRank">
    SELECT
        b.user_id AS userId,
        u.nickname AS nickname,
        SUM(b.score) AS totalBet
    FROM dora_guessing_bet b
    LEFT JOIN dora_guessing_user u
        ON b.user_id = u.user_id
    GROUP BY b.user_id, u.nickname
    ORDER BY totalBet DESC
    LIMIT 10
</select>

Android端

API接口
package site.doramusic.app.http.service

import dora.http.retrofit.ApiService
import okhttp3.RequestBody
import retrofit2.Call
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Query
import site.doramusic.app.http.ApiResult
import site.doramusic.app.http.DoraGuessingInfoWithItems
import site.doramusic.app.http.DoraGuessingRank
import site.doramusic.app.http.DoraGuessingReward
import site.doramusic.app.http.DoraGuessingUser
import site.doramusic.app.http.GuestSession

interface GuessingService : ApiService {

    companion object {

        /**
         * 深情端口的朵拉音乐服务器。
         */
        const val SERVER_URL = "http://dorachat.com:3000"
    }

    /**
     * 生成游客令牌。
     */
    @GET("guest/init")
    fun initGuest(): Call<ApiResult<GuestSession>>

    /**
     * 检测游客令牌。
     */
    @GET("guest/checkToken")
    fun checkGuestToken(@Query("token") token: String): Call<ApiResult<Boolean>>

    /**
     * 设置昵称。
     */
    @POST("guessing/setNickname")
    fun setNickname(@Body body: RequestBody): Call<ApiResult<Boolean>>

    /**
     * 获取竞猜用户信息。
     */
    @POST("guessing/profile")
    fun getProfile(@Body body: RequestBody): Call<ApiResult<DoraGuessingUser>>

    /**
     * 获取竞猜列表。
     */
    @POST("guessing/list")
    fun getList(@Body body: RequestBody): Call<ApiResult<MutableList<DoraGuessingInfoWithItems>>>

    /**
     * 投注。
     */
    @POST("guessing/bet")
    fun bet(@Body body: RequestBody): Call<ApiResult<Boolean>>

    /**
     * 我的竞猜,结算后的竞猜奖励结果列表。
     */
    @POST("guessing/reward")
    fun getRewardList(@Body body: RequestBody): Call<ApiResult<MutableList<DoraGuessingReward>>>

    /**
     * 领取积分。
     */
    @POST("guessing/claim")
    fun claim(@Body body: RequestBody): Call<ApiResult<Long>>

    /**
     * 竞猜排行榜。0 - 胜率榜,1 - 盈亏榜,2 - 投注榜。
     */
    @GET("guessing/rank")
    fun getRank(@Query("type") type: Int): Call<ApiResult<MutableList<DoraGuessingRank>>>
}

以上为客户端需要用到的全部接口,不包括管理系统的。

登录认证
private suspend fun initGuest() : GuestSession? {
    val resp = try {
         api(GuessingService::class) {
            initGuest()
        }?.data
    } catch (e: Exception) {
        showShortToast(e.toString())
        null
    }
    SPUtils.writeString(requireContext(), "token", resp?.token)
    SPUtils.writeString(requireContext(), "userId", resp?.userId)
    return resp
}

private suspend fun getAvailableGuestSession(): GuestSession? {
    val token = SPUtils.readString(requireContext(), "token")
    val userId = SPUtils.readString(requireContext(), "userId")
    // 本地没缓存
    if (TextUtils.isEmpty(token) || TextUtils.isEmpty(userId)) {
        return initGuest()
    }
    // 服务端验证 token
    val resp = result(GuessingService::class) {
        checkGuestToken(token)
    }
    return if (resp?.data == true) {
        GuestSession(token, userId)
    } else {
        initGuest()
    }
}

private fun loadGuessing(binding: FragmentHomeBinding) {
    net {
        val data = result(AdService::class) { isGuessingOpened(PRODUCT_NAME) }?.data
        val visible = data?.visible
        val guessingEnable = data?.configValue
        if (visible == 1 && guessingEnable == "true") {
            binding.rlGuessingContent.visibility = View.VISIBLE
            var token: String?
            var userId: String?
            if (UserManager.ins?.currentUser == null) {
                val guestSession = getAvailableGuestSession()
                token = guestSession?.token
                userId = guestSession?.userId
            } else {
                // 由于SDK v1.1.2版本暂未将访问token加载到user对象,直接从AuthManager拿
                token = AuthManager.getAccessToken()
                userId = UserManager.ins?.currentUser?.erc20
            }
            binding.rlGuessingContent.setOnClickListener {
                open(ARoutePath.ACTIVITY_GUESSING) {
                    withString("token", token)
                    withString("userId", userId)
                }
            }
        }
        val topic = result(AdService::class) { getGuessingTopic(PRODUCT_NAME) }?.data?.configValue
        if (TextUtils.isNotEmpty(topic)) {
            binding.tvGuessingTopic.text = topic
        }
    }
}

首先读取竞猜活动是否开启以及竞猜主题的配置接口,打开竞猜界面之前先尝试获取账号是否登录,没有登录账号的情况下,直接生成游客Token进行登录。游客Token只有2个月有效期,不可续期,但对世界杯的赛程足够了。

图章控件
package dora.widget

import android.content.Context
import android.graphics.*
import android.graphics.drawable.Drawable
import android.util.AttributeSet
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.widget.FrameLayout
import dora.widget.markview.R
import kotlin.math.roundToInt

class DoraMarkView @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null
) : FrameLayout(context, attrs) {

    data class TextMark(
        val text: String,
        val textColor: Int = Color.WHITE,
        val bgColor: Int = Color.RED,
        val textSizeSp: Float = 12f,
        val cornerRadius: Float = 12f,
        val paddingH: Int = 12,
        val paddingV: Int = 6,
        val gravity: Int = Gravity.TOP or Gravity.END,
        val margin: Int = 8
    )

    data class DrawableMark(
        val drawable: Drawable,
        val gravity: Int = Gravity.TOP or Gravity.END,
        val margin: Int = 8,
        val rotation: Float = 0f // 旋转角度
    )

    private val textMarks = mutableListOf<TextMark>()
    private val drawableMarks = mutableListOf<DrawableMark>()
    private var markView: View? = null
    private var markVisible: Boolean = true

    private val outRect = Rect()
    private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG)
    private val bgPaint = Paint(Paint.ANTI_ALIAS_FLAG)

    init {
        setWillNotDraw(false)
        initAttrs(context, attrs)
    }

    private fun initAttrs(context: Context, attrs: AttributeSet?) {
        attrs ?: return
        val ta = context.obtainStyledAttributes(attrs, R.styleable.DoraMarkView)

        ta.getDrawable(R.styleable.DoraMarkView_dview_mv_drawable)?.let {
            addDrawableMark(it)
        }

        val layoutId = ta.getResourceId(R.styleable.DoraMarkView_dview_mv_layout, 0)
        if (layoutId != 0) {
            markView = LayoutInflater.from(context).inflate(layoutId, this, false)
        }

        markVisible = ta.getBoolean(R.styleable.DoraMarkView_dview_mv_visible, true)
        ta.recycle()
    }

    fun addDrawableMark(
        drawable: Drawable,
        gravity: Int = Gravity.TOP or Gravity.END,
        margin: Int = 8,
        rotation: Float = 0f
    ) {
        drawableMarks.add(DrawableMark(drawable, gravity, margin, rotation))
        invalidate()
    }

    fun clearDrawableMarks() {
        drawableMarks.clear()
        invalidate()
    }

    fun setMarkView(view: View?) {
        markView = view
        requestLayout()
    }

    fun setMarkVisible(visible: Boolean) {
        markVisible = visible
        invalidate()
    }

    fun addTextMark(mark: TextMark) {
        textMarks.add(mark)
        invalidate()
    }

    fun clearTextMarks() {
        textMarks.clear()
        invalidate()
    }

    override fun dispatchDraw(canvas: Canvas) {
        super.dispatchDraw(canvas)
        if (!markVisible) return

        drawDrawableMarks(canvas)
        drawViewMark(canvas)
        drawTextMarks(canvas)
    }

    /** ================= Drawable 绘制(支持旋转) ================= */
    private fun drawDrawableMarks(canvas: Canvas) {
        drawableMarks.forEach { mark ->

            val rect = Rect()
            Gravity.apply(
                mark.gravity,
                mark.drawable.intrinsicWidth,
                mark.drawable.intrinsicHeight,
                Rect(
                    paddingLeft + mark.margin,
                    paddingTop + mark.margin,
                    width - paddingRight - mark.margin,
                    height - paddingBottom - mark.margin
                ),
                rect
            )

            mark.drawable.bounds = rect

            val cx = rect.exactCenterX()
            val cy = rect.exactCenterY()

            canvas.save()

            // 围绕中心旋转
            if (mark.rotation != 0f) {
                canvas.rotate(mark.rotation, cx, cy)
            }

            mark.drawable.draw(canvas)

            canvas.restore()
        }
    }

    private fun drawViewMark(canvas: Canvas) {
        val view = markView ?: return

        val contentWidth = width - paddingLeft - paddingRight
        val contentHeight = height - paddingTop - paddingBottom

        measureChild(
            view,
            MeasureSpec.makeMeasureSpec(contentWidth, MeasureSpec.AT_MOST),
            MeasureSpec.makeMeasureSpec(contentHeight, MeasureSpec.AT_MOST)
        )

        val markRect = Rect()
        Gravity.apply(
            Gravity.TOP or Gravity.END,
            view.measuredWidth,
            view.measuredHeight,
            Rect(
                paddingLeft,
                paddingTop,
                width - paddingRight,
                height - paddingBottom
            ),
            markRect
        )

        canvas.save()
        canvas.translate(markRect.left.toFloat(), markRect.top.toFloat())
        view.layout(0, 0, view.measuredWidth, view.measuredHeight)
        view.draw(canvas)
        canvas.restore()
    }

    private fun drawTextMarks(canvas: Canvas) {
        textMarks.forEach { mark ->

            textPaint.color = mark.textColor
            textPaint.textSize = mark.textSizeSp * resources.displayMetrics.scaledDensity
            textPaint.typeface = Typeface.DEFAULT_BOLD

            bgPaint.color = mark.bgColor

            val textWidth = textPaint.measureText(mark.text)
            val textHeight = textPaint.fontMetrics.run { descent - ascent }

            val bgWidth = (textWidth + mark.paddingH * 2).roundToInt()
            val bgHeight = (textHeight + mark.paddingV * 2).roundToInt()

            val parentRect = Rect(
                paddingLeft + mark.margin,
                paddingTop + mark.margin,
                width - paddingRight - mark.margin,
                height - paddingBottom - mark.margin
            )

            Gravity.apply(mark.gravity, bgWidth, bgHeight, parentRect, outRect)

            val rectF = RectF(outRect)
            canvas.drawRoundRect(rectF, mark.cornerRadius, mark.cornerRadius, bgPaint)

            val textX = rectF.left + mark.paddingH
            val textY = rectF.top + mark.paddingV - textPaint.fontMetrics.ascent

            canvas.drawText(mark.text, textX, textY, textPaint)
        }
    }
}

我们知道,如果想要在控件上面再添加控件,有两种方式。第一种,最简单,使用FrameLayout,直接在xml中进行布局。这种方式,最为繁琐,要写很多逻辑代码,和业务耦合严重;第二种,则是在ViewGroup中绘制图片或View。我们可以支持文字、图片甚至是View的绘制。一个很好的绘制时机就是重写ViewGroup的dispatchDraw(),这个老哥会在它的子控件布局完成后再添油加醋,哦不,是再添加一些绘制,而不影响已经布局好的子控件内容。这样我们做图章的效果就相当轻松了,直接在原有内容上盖章。由于我这图片本身就是旋转好的,所以我使用时就不再旋转了。很多人不知道如何把View给绘制到ViewGroup上,我们重点看这个方法。

private fun drawViewMark(canvas: Canvas) { 
    val view = markView ?: return 
    val contentWidth = width - paddingLeft - paddingRight 
    val contentHeight = height - paddingTop - paddingBottom 
    measureChild(view, MeasureSpec.makeMeasureSpec(contentWidth, MeasureSpec.AT_MOST), MeasureSpec.makeMeasureSpec(contentHeight, MeasureSpec.AT_MOST)) 
    val markRect = Rect() 
    Gravity.apply(Gravity.TOP or Gravity.END, view.measuredWidth, view.measuredHeight, Rect(paddingLeft, paddingTop, width - paddingRight, height - paddingBottom), markRect) 
    canvas.save() 
    canvas.translate(markRect.left.toFloat(), markRect.top.toFloat())
    view.layout(0, 0, view.measuredWidth, view.measuredHeight)  
    view.draw(canvas) 
    canvas.restore() 
}

view不是塞到容器里,而是绘制到canvas上的。不会参与LayoutParams体系,但View本身仍然需要layout()才能正常绘制。我们来归纳一下绘制流程。

  1. 创建要绘制的View,这里直接用成员变量
  2. 调用measureChild测量子控件
  3. save画布,记住原来的坐标
  4. translate平移画布,绘制要绘制的内容,translate改变绘制的原点,即后面绘制到(0,0)坐标,实际视觉上不是在原点位置,而是在平移的坐标位置
  5. layout决定View在父容器中的“位置和大小”
  6. 调用view自身的draw方法,把自己画到画布上
  7. restore还原画布状态

那么这样我们就可以把Mark标记绘制到ViewGroup上了。

后记

朵拉音乐客户端下载地址:
👉 www.pgyer.com/doramusic

GitHub 开源地址:
👉 github.com/dora4/DoraM…

如果觉得项目还不错,欢迎点个 ⭐ Star 支持一下~
我会持续优化,慢慢打磨体验 🚀