lib/wechat/official_account/web_page.ex

defmodule WeChat.WebPage do
  @moduledoc """
  网页开发

  ## API Docs
    * [网页授权](https://developers.weixin.qq.com/doc/offiaccount/OA_Web_Apps/Wechat_webpage_authorization.html){:target="_blank"}
    * [JS-SDK](https://developers.weixin.qq.com/doc/offiaccount/OA_Web_Apps/JS-SDK.html){:target="_blank"}
  """
  alias WeChat.{Requester, Utils, Card, User, Storage.Cache}

  @typedoc """
  授权范围
    * `"snsapi_base"` - 静默授权
    * `"snsapi_userinfo"` - 用户的基本信息(无须关注)
  """
  @type scope :: String.t()
  @typedoc "重定向后会带上state参数,开发者可以填写a-zA-Z0-9的参数值,最多128字节"
  @type state :: String.t()
  @typedoc "授权后重定向的回调链接地址, 请使用 urlEncode 对链接进行处理"
  @type redirect_uri :: String.t()
  @type code :: String.t()
  @type access_token :: String.t()
  @type refresh_token :: String.t()
  @type ticket :: String.t()
  @type wx_card_ticket :: ticket
  @type js_api_ticket :: ticket
  @type url :: String.t()
  @type signature :: String.t()
  @type timestamp :: non_neg_integer
  @type nonce_str :: String.t()
  @type js_sdk_config :: %{
          appId: WeChat.appid(),
          signature: signature,
          timestamp: timestamp,
          nonceStr: nonce_str
        }
  @type card_ext :: String.t()
  @type card_config :: %{cardId: Card.card_id(), cardExt: card_ext}

  @typedoc """
  JS API的临时票据类型
    * `"jsapi"` - JS-SDK Config
    * `"wx_card"` - 微信卡券
  """
  @type js_api_ticket_type :: String.t()

  @doc_link "https://developers.weixin.qq.com/doc/offiaccount/OA_Web_Apps"
  @component_doc_link "https://developers.weixin.qq.com/doc/oplatform/Third-party_Platforms/Official_Accounts/official_account_website_authorization.html"

  @doc """
  网页授权 - 请求`code`

  官方文档:
  * [Official Account](#{@doc_link}/Wechat_webpage_authorization.html#0){:target="_blank"}
  * [Component](#{@component_doc_link}){:target="_blank"}

  关于网页授权回调域名的说明

  - 在微信公众号请求用户网页授权之前,开发者需要先到公众平台官网中的 “`开发` - `接口权限` - `网页服务` - `网页帐号` - `网页授权获取用户基本信息`” 的配置选项中,
  修改授权回调域名。请注意,这里填写的是域名(是一个字符串),而不是URL,因此请勿加 `http://` 等协议头;
  - 授权回调域名配置规范为全域名,比如需要网页授权的域名为:`www.qq.com`,配置以后此域名下面的页面 `http://www.qq.com/music.html`、
  `http://www.qq.com/login.html` 都可以进行 `OAuth2.0` 鉴权。但 `http://pay.qq.com`、`http://music.qq.com` 、 `http://qq.com`
  无法进行 `OAuth2.0` 鉴权
  - 如果公众号登录授权给了第三方开发者来进行管理,则不必做任何设置,由第三方代替公众号实现网页授权即可
  """
  @spec oauth2_authorize_url(WeChat.client(), redirect_uri, scope, state) :: url
  def oauth2_authorize_url(client, redirect_uri, scope \\ "snsapi_base", state \\ "") do
    [
      "https://open.weixin.qq.com/connect/oauth2/authorize?",
      ["appid=", client.appid()],
      ["&redirect_uri=", URI.encode_www_form(redirect_uri)],
      "&response_type=code",
      ["&scope=", scope],
      if match?("", state) do
        []
      else
        ["&state=", state]
      end,
      if client.by_component?() do
        ["&component_appid=", client.component_appid(), "#wechat_redirect"]
      else
        "#wechat_redirect"
      end
    ]
    |> IO.iodata_to_binary()
  end

  @doc """
  网页授权 - 通过`code`换取`access_token`

  官方文档:
    * [Official Account](#{@doc_link}/Wechat_webpage_authorization.html#1){:target="_blank"}
    * [Component](#{@component_doc_link}){:target="_blank"}
  """
  @spec code2access_token(WeChat.client(), code) :: WeChat.response()
  def code2access_token(client, code) do
    if client.by_component?() do
      component_appid = client.component_appid()

      client.get(
        "/sns/oauth2/component/access_token",
        query: [
          appid: client.appid(),
          code: code,
          grant_type: "authorization_code",
          component_appid: component_appid,
          component_access_token: Cache.get_cache(component_appid, :component_access_token)
        ]
      )
    else
      client.get("/sns/oauth2/access_token",
        query: [
          appid: client.appid(),
          secret: client.appsecret(),
          code: code,
          grant_type: "authorization_code"
        ]
      )
    end
  end

  @doc """
  网页授权 - 刷新`access_token`

  由于`access_token`拥有较短的有效期,当`access_token`超时后,可以使用`refresh_token`进行刷新,

  `refresh_token`有效期为30天,当`refresh_token`失效之后,需要用户重新授权。

  官方文档:
    * [Official Account](#{@doc_link}/Wechat_webpage_authorization.html#2){:target="_blank"}
    * [Component](#{@component_doc_link}){:target="_blank"}
  """
  @spec refresh_token(WeChat.client(), refresh_token) :: WeChat.response()
  def refresh_token(client, refresh_token) do
    if client.by_component?() do
      component_appid = client.component_appid()

      client.get("/sns/oauth2/component/refresh_token",
        query: [
          appid: client.appid(),
          grant_type: "refresh_token",
          refresh_token: refresh_token,
          component_appid: component_appid,
          component_access_token: Cache.get_cache(component_appid, :component_access_token)
        ]
      )
    else
      client.get("/sns/oauth2/refresh_token",
        query: [
          appid: client.appid(),
          grant_type: "refresh_token",
          refresh_token: refresh_token
        ]
      )
    end
  end

  @doc """
  网页授权 - 拉取用户信息(需`scope`为`snsapi_userinfo`) -
  [官方文档](#{@doc_link}/Wechat_webpage_authorization.html#3){:target="_blank"}

  如果网页授权作用域为`snsapi_userinfo`,则此时开发者可以通过access_token和openid拉取用户信息了.
  """
  @spec user_info(WeChat.openid(), access_token) :: WeChat.response()
  def user_info(openid, access_token) do
    Requester.OfficialAccount.get("/sns/userinfo",
      query: [access_token: access_token, openid: openid]
    )
  end

  @doc "See `user_info/2`"
  @spec user_info(WeChat.requester(), WeChat.openid(), access_token) :: WeChat.response()
  def user_info(requester, openid, access_token) when is_atom(requester) do
    requester.get("/sns/userinfo",
      query: [access_token: access_token, openid: openid]
    )
  end

  @spec user_info(WeChat.openid(), access_token, User.lang()) :: WeChat.response()
  def user_info(openid, access_token, lang) do
    Requester.OfficialAccount.get("/sns/userinfo",
      query: [
        access_token: access_token,
        openid: openid,
        lang: lang
      ]
    )
  end

  @doc "See `user_info/2`"
  @spec user_info(WeChat.requester(), WeChat.openid(), access_token, User.lang()) ::
          WeChat.response()
  def user_info(requester, openid, access_token, lang) do
    requester.get("/sns/userinfo",
      query: [
        access_token: access_token,
        openid: openid,
        lang: lang
      ]
    )
  end

  @doc """
  网页授权 - 检验授权凭证(`access_token`)是否有效 -
  [官方文档](#{@doc_link}/Wechat_webpage_authorization.html#4){:target="_blank"}
  """
  @spec auth(WeChat.openid(), access_token) :: WeChat.response()
  def auth(requester \\ Requester.OfficialAccount, openid, access_token) do
    requester.get("/sns/auth",
      query: [access_token: access_token, openid: openid]
    )
  end

  @doc """
  生成 JS-SDK 配置 -
  [官方文档](#{@doc_link}/JS-SDK.html#4){:target="_blank"}

  返回 `maps` 包含: `appId/signature/timestamp/nonceStr`
  """
  @spec js_sdk_config(WeChat.client(), url) :: js_sdk_config
  def js_sdk_config(client, url) do
    appid = client.appid()

    appid
    |> Cache.get_cache(:js_api_ticket)
    |> sign_js_sdk(url, appid)
  end

  @doc """
  生成 JS-SDK 配置(by ticket) -
  [官方文档](#{@doc_link}/JS-SDK.html#4){:target="_blank"}

  返回 `maps` 包含: `appId/signature/timestamp/nonceStr`
  """
  @spec sign_js_sdk(js_api_ticket, url, WeChat.appid()) :: js_sdk_config
  def sign_js_sdk(jsapi_ticket, url, appid) do
    url = String.replace(url, ~r/\#.*/, "")
    nonce_str = Utils.random_string(16)
    timestamp = Utils.now_unix()

    signature =
      Utils.sha1(
        "jsapi_ticket=#{jsapi_ticket}&noncestr=#{nonce_str}&timestamp=#{timestamp}&url=#{url}"
      )

    %{appId: appid, signature: signature, timestamp: timestamp, nonceStr: nonce_str}
  end

  @doc """
  生成微信卡券配置 - 添加卡券

  ## API Docs
    * [微信卡券](#{@doc_link}/JS-SDK.html#53){:target="_blank"}
    * [卡券扩展字段及签名生成算法](#{@doc_link}/JS-SDK.html#65){:target="_blank"}
  """
  @spec add_card_config(WeChat.client(), Card.card_id(), outer_str :: String.t()) :: card_config
  def add_card_config(client, card_id, outer_str) do
    appid = client.appid()

    card_ext =
      appid
      |> Cache.get_cache(:wx_card_ticket)
      |> sign_card(card_id)
      |> Map.merge(%{appid: client.appid(), outer_str: outer_str})
      |> Jason.encode!()

    %{cardId: card_id, cardExt: card_ext}
  end

  @doc """
  生成微信卡券配置 - 添加卡券(绑定`openid`)

  ## API Docs
    * [微信卡券](#{@doc_link}/JS-SDK.html#53){:target="_blank"}
    * [卡券扩展字段及签名生成算法](#{@doc_link}/JS-SDK.html#65){:target="_blank"}
  """
  @spec add_card_config(WeChat.client(), Card.card_id(), outer_str :: String.t(), WeChat.openid()) ::
          map
  def add_card_config(client, card_id, outer_str, openid) do
    appid = client.appid()

    card_ext =
      appid
      |> Cache.get_cache(:wx_card_ticket)
      |> sign_card(card_id, openid)
      |> Map.merge(%{appid: client.appid(), outer_str: outer_str, openid: openid})
      |> Jason.encode!()

    %{cardId: card_id, cardExt: card_ext}
  end

  @doc "See `sign_card/1`"
  @spec sign_card(wx_card_ticket, Card.card_id()) :: map
  def sign_card(wx_card_ticket, card_id), do: sign_card([wx_card_ticket, card_id])

  @doc "See `sign_card/1`"
  @spec sign_card(wx_card_ticket, Card.card_id(), WeChat.openid()) :: map
  def sign_card(wx_card_ticket, card_id, openid), do: sign_card([wx_card_ticket, card_id, openid])

  @doc """
  卡券签名 -
  [官方文档](#{@doc_link}/JS-SDK.html#65){:target="_blank"}
  """
  @compile {:inline, sign_card: 1}
  @spec sign_card(list :: [String.t()]) :: map
  def sign_card(list) do
    nonce_str = Utils.random_string(16)
    timestamp = Utils.now_unix()
    timestamp_str = Integer.to_string(timestamp)
    signature = Utils.sha1([timestamp_str, nonce_str | list])

    %{signature: signature, timestamp: timestamp, nonce_str: nonce_str}
  end

  @doc """
  获取`api_ticket` -
  [官方文档](#{@doc_link}/JS-SDK.html#62){:target="_blank"}
  """
  @spec get_ticket(WeChat.client(), js_api_ticket_type) :: WeChat.response()
  def get_ticket(client, type) do
    client.get("/cgi-bin/ticket/getticket",
      query: [type: type, access_token: client.get_access_token()]
    )
  end
end